fix: 初验针对修改

This commit is contained in:
zxr
2026-09-09 16:42:21 +08:00
parent eb2722d8a0
commit b6ffe2fcea
40 changed files with 2179 additions and 279 deletions

View File

@@ -0,0 +1,154 @@
package files
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/infra"
"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"
"git.apinb.com/ops/files/internal/signing"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func Access(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, newAccessInstruction(fileObject, normalizeDisposition(ctx.Query("disposition"), "inline")))
}
func BatchAccess(ctx *gin.Context) {
actor, ok := auth.FromContext(ctx)
if !ok || actor.Type != auth.ActorTypeService {
infra.Response.Error(ctx, fileerrors.ErrUnauthorizedOperation)
return
}
var request BatchAccessRequest
if err := ctx.ShouldBindJSON(&request); err != nil || len(request.FileIDs) == 0 || len(request.FileIDs) > 100 {
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
return
}
fileIDs := uniqueFileIDs(request.FileIDs)
if len(fileIDs) != len(request.FileIDs) {
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
return
}
var fileObjects []models.FileObject
if err := impl.DBService.Where(
"identity IN ? AND owner_type = ? AND owner_identity = ? AND status = ?",
fileIDs, auth.ActorTypeService, actor.Identity, models.FileStatusReady,
).Find(&fileObjects).Error; err != nil {
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
return
}
if len(fileObjects) != len(fileIDs) {
infra.Response.Error(ctx, fileerrors.ErrFileNotFound)
return
}
byIdentity := make(map[string]models.FileObject, len(fileObjects))
for _, fileObject := range fileObjects {
byIdentity[fileObject.Identity] = fileObject
}
disposition := normalizeDisposition(request.Disposition, "inline")
response := make([]AccessInstruction, 0, len(fileIDs))
for _, fileID := range fileIDs {
response = append(response, newAccessInstruction(byIdentity[fileID], disposition))
}
infra.Response.Success(ctx, response)
}
func SignedAccess(ctx *gin.Context) {
fileID := strings.TrimSpace(ctx.Param("identity"))
disposition := normalizeDisposition(ctx.Query("disposition"), "inline")
expiresUnix, err := strconv.ParseInt(ctx.Query("expires"), 10, 64)
if err != nil || !signing.VerifyAccess(
config.Spec.Storage.SigningSecret, fileID, time.Unix(expiresUnix, 0), disposition,
ctx.Query("signature"), time.Now(),
) {
ctx.JSON(http.StatusForbidden, gin.H{"message": "文件访问签名无效或已过期"})
return
}
fileObject, ok := loadReadyFile(ctx, fileID, "")
if !ok {
return
}
streamFile(ctx, fileObject, disposition)
}
func PublicAccess(ctx *gin.Context) {
fileObject, ok := loadReadyFile(ctx, strings.TrimSpace(ctx.Param("identity")), models.FileVisibilityPublic)
if !ok {
return
}
streamFile(ctx, fileObject, normalizeDisposition(ctx.Query("disposition"), "inline"))
}
func newAccessInstruction(fileObject models.FileObject, disposition string) AccessInstruction {
baseURL := strings.TrimRight(config.Spec.Storage.ExternalBaseURL, "/")
result := AccessInstruction{
FileID: fileObject.Identity, Filename: fileObject.OriginalName, ContentType: fileObject.ContentType,
Size: fileObject.ActualSize, Disposition: disposition,
}
if fileObject.Visibility == models.FileVisibilityPublic {
result.URL = fmt.Sprintf("%s/v1/public/files/%s/content?disposition=%s", baseURL, fileObject.Identity, disposition)
return result
}
expiresAt := time.Now().Add(time.Duration(config.Spec.Storage.AccessTTLSeconds) * time.Second).UTC()
signature := signing.SignAccess(config.Spec.Storage.SigningSecret, fileObject.Identity, expiresAt, disposition)
result.ExpiresAt = expiresAt
result.URL = fmt.Sprintf("%s/v1/access/%s/content?expires=%d&disposition=%s&signature=%s",
baseURL, fileObject.Identity, expiresAt.Unix(), disposition, signature)
return result
}
func loadReadyFile(ctx *gin.Context, fileID string, visibility models.FileVisibility) (models.FileObject, bool) {
if fileID == "" {
ctx.Status(http.StatusNotFound)
return models.FileObject{}, false
}
query := impl.DBService.Where("identity = ? AND status = ?", fileID, models.FileStatusReady)
if visibility != "" {
query = query.Where("visibility = ?", visibility)
}
var fileObject models.FileObject
if err := query.First(&fileObject).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.Status(http.StatusNotFound)
} else {
ctx.Status(http.StatusInternalServerError)
}
return models.FileObject{}, false
}
return fileObject, true
}
func uniqueFileIDs(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}

View File

@@ -7,10 +7,19 @@ import (
fileerrors "git.apinb.com/ops/files/internal/errors"
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/models"
"git.apinb.com/ops/files/internal/runtimeinfo"
"github.com/gin-gonic/gin"
)
func CompleteUpload(ctx *gin.Context) {
success := false
defer func() {
if success {
runtimeinfo.RecordUploadSuccess()
} else {
runtimeinfo.RecordUploadFailure()
}
}()
fileObject, ok := loadOwnedFile(ctx)
if !ok {
return
@@ -27,7 +36,7 @@ func CompleteUpload(ctx *gin.Context) {
objectInfo, err := impl.StorageService.Stat(ctx.Request.Context(), fileObject.ObjectKey)
if err != nil {
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
infra.Response.Error(ctx, fileerrors.ErrStorageOperation)
return
}
if objectInfo.Size != fileObject.ExpectedSize {
@@ -41,7 +50,7 @@ func CompleteUpload(ctx *gin.Context) {
Updates(map[string]any{
"status": models.FileStatusReady,
"actual_size": objectInfo.Size,
"e_tag": objectInfo.ETag,
"storage_etag": objectInfo.StorageETag,
"completed_at": completedAt,
})
if result.Error != nil {
@@ -55,7 +64,8 @@ func CompleteUpload(ctx *gin.Context) {
fileObject.Status = models.FileStatusReady
fileObject.ActualSize = objectInfo.Size
fileObject.ETag = objectInfo.ETag
fileObject.StorageETag = objectInfo.StorageETag
fileObject.CompletedAt = &completedAt
success = true
infra.Response.Success(ctx, newFileResponse(fileObject))
}

View File

@@ -9,6 +9,7 @@ import (
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/lifecycle"
"git.apinb.com/ops/files/internal/models"
"git.apinb.com/ops/files/internal/runtimeinfo"
"github.com/gin-gonic/gin"
)
@@ -37,14 +38,17 @@ func Delete(ctx *gin.Context) {
defer cancel()
if err := impl.StorageService.Delete(operationCtx, fileObject.ObjectKey); err != nil {
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
runtimeinfo.RecordDeleteFailure()
infra.Response.Error(ctx, fileerrors.ErrStorageOperation)
return
}
if err := lifecycle.FinalizeDeletion(impl.DBService.WithContext(operationCtx), fileObject.ID, lease.Token); err != nil {
runtimeinfo.RecordDeleteFailure()
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
return
}
runtimeinfo.RecordDeleteSuccess()
infra.Response.Success(ctx, nil)
}

View File

@@ -26,6 +26,19 @@ func Detail(ctx *gin.Context) {
infra.Response.Success(ctx, newFileResponse(fileObject))
}
// Download 通过文件服务鉴权读取文件内容。
func Download(ctx *gin.Context) {
fileObject, ok := loadOwnedFile(ctx)
if !ok {
return
}
if fileObject.Status != models.FileStatusReady {
infra.Response.Error(ctx, fileerrors.ErrFileNotFound)
return
}
streamFile(ctx, fileObject, normalizeDisposition(ctx.Query("disposition"), "attachment"))
}
func loadOwnedFile(ctx *gin.Context) (models.FileObject, bool) {
actor, ok := auth.FromContext(ctx)
if !ok {
@@ -57,12 +70,10 @@ func loadOwnedFile(ctx *gin.Context) (models.FileObject, bool) {
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,
StorageETag: fileObject.StorageETag,
Status: fileObject.Status,
}
}

View File

@@ -1,6 +1,7 @@
package files
import (
"errors"
"math"
"path/filepath"
"strings"
@@ -14,6 +15,7 @@ import (
fileerrors "git.apinb.com/ops/files/internal/errors"
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/models"
"git.apinb.com/ops/files/internal/storage"
"github.com/gin-gonic/gin"
)
@@ -54,34 +56,43 @@ func InitUpload(ctx *gin.Context) {
}
contentType := strings.TrimSpace(request.ContentType)
if contentType == "" || utf8.RuneCountInString(contentType) > 255 {
if contentType == "" || utf8.RuneCountInString(contentType) > 255 || strings.ContainsAny(contentType, "\r\n") {
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)
expiresAt := time.Now().Add(time.Duration(config.Spec.Storage.AccessTTLSeconds) * time.Second)
upload, err := impl.StorageService.PrepareUpload(ctx.Request.Context(), storage.UploadRequest{
FileID: fileID, ObjectKey: objectKey, ContentType: contentType,
ExpectedSize: request.Size, ExpiresAt: expiresAt,
})
if err != nil {
infra.Response.Error(ctx, fileerrors.ErrObjectStorageOperation)
if errors.Is(err, storage.ErrInsufficientSpace) {
infra.Response.Error(ctx, fileerrors.ErrStorageSpaceLow)
} else {
infra.Response.Error(ctx, fileerrors.ErrStorageOperation)
}
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,
Identity: fileID,
Namespace: namespace,
Provider: impl.StorageService.Provider(),
StorageContainer: impl.StorageService.Container(),
ObjectKey: objectKey,
OriginalName: filename,
Extension: extension,
ContentType: contentType,
ExpectedSize: request.Size,
Status: models.FileStatusPending,
Visibility: models.FileVisibilityPrivate,
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)
@@ -89,9 +100,8 @@ func InitUpload(ctx *gin.Context) {
}
infra.Response.Success(ctx, InitUploadResponse{
FileID: fileID,
ObjectKey: objectKey,
Upload: upload,
FileID: fileID,
Upload: upload,
})
}

View File

@@ -0,0 +1,78 @@
package files
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.apinb.com/ops/files/internal/config"
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/models"
"git.apinb.com/ops/files/internal/signing"
"git.apinb.com/ops/files/internal/storage"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func LocalUpload(ctx *gin.Context) {
receiver, ok := impl.StorageService.(storage.UploadReceiver)
if !ok || impl.StorageService.Provider() != "local" {
ctx.Status(http.StatusNotFound)
return
}
fileID := strings.TrimSpace(ctx.Param("identity"))
expiresUnix, err := strconv.ParseInt(ctx.Query("expires"), 10, 64)
if err != nil || fileID == "" {
ctx.Status(http.StatusForbidden)
return
}
var fileObject models.FileObject
if err := impl.DBService.Where("identity = ?", fileID).First(&fileObject).Error; err != nil {
ctx.Status(http.StatusForbidden)
return
}
request := storage.UploadRequest{
FileID: fileID, ObjectKey: fileObject.ObjectKey, ContentType: fileObject.ContentType,
ExpectedSize: fileObject.ExpectedSize, ExpiresAt: time.Unix(expiresUnix, 0),
}
if !signing.VerifyUpload(config.Spec.Storage.SigningSecret, ctx.Query("signature"), request, time.Now()) ||
ctx.GetHeader("Content-Type") != fileObject.ContentType || ctx.Request.ContentLength != fileObject.ExpectedSize {
ctx.Status(http.StatusForbidden)
return
}
release, err := receiver.AcquireUpload(ctx.Request.Context(), fileID)
if err != nil {
if errors.Is(err, storage.ErrUploadLocked) {
ctx.Status(http.StatusConflict)
} else {
ctx.Status(http.StatusInternalServerError)
}
return
}
defer release()
if err := impl.DBService.Where("id = ?", fileObject.ID).First(&fileObject).Error; err != nil {
ctx.Status(http.StatusConflict)
return
}
if fileObject.Status != models.FileStatusPending || fileObject.ExpiresAt == nil || !fileObject.ExpiresAt.After(time.Now()) {
ctx.Status(http.StatusConflict)
return
}
request.ObjectKey = fileObject.ObjectKey
request.ContentType = fileObject.ContentType
request.ExpectedSize = fileObject.ExpectedSize
if _, err := receiver.ReceiveUpload(ctx.Request.Context(), request, ctx.Request.Body); err != nil {
if errors.Is(err, storage.ErrInsufficientSpace) {
ctx.Status(http.StatusInsufficientStorage)
} else if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.Status(http.StatusConflict)
} else {
ctx.Status(http.StatusInternalServerError)
}
return
}
ctx.Status(http.StatusNoContent)
}

View File

@@ -0,0 +1,52 @@
package files
import (
"fmt"
"strconv"
"strings"
"git.apinb.com/ops/files/internal/storage"
)
func parseByteRange(value string, size int64) (*storage.ByteRange, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
if size <= 0 || !strings.HasPrefix(value, "bytes=") {
return nil, fmt.Errorf("文件读取范围无效")
}
rangeValue := strings.TrimPrefix(value, "bytes=")
if strings.Contains(rangeValue, ",") {
return nil, fmt.Errorf("不支持多段文件读取")
}
parts := strings.SplitN(rangeValue, "-", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("文件读取范围无效")
}
if parts[0] == "" {
suffixLength, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || suffixLength <= 0 {
return nil, fmt.Errorf("文件读取范围无效")
}
if suffixLength > size {
suffixLength = size
}
return &storage.ByteRange{Start: size - suffixLength, End: size - 1}, nil
}
start, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || start < 0 || start >= size {
return nil, fmt.Errorf("文件读取范围无效")
}
end := size - 1
if parts[1] != "" {
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil || end < start {
return nil, fmt.Errorf("文件读取范围无效")
}
if end >= size {
end = size - 1
}
}
return &storage.ByteRange{Start: start, End: end}, nil
}

View File

@@ -0,0 +1,59 @@
package files
import (
"fmt"
"mime"
"net/http"
"strings"
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/models"
"git.apinb.com/ops/files/internal/runtimeinfo"
"github.com/gin-gonic/gin"
)
func streamFile(ctx *gin.Context, fileObject models.FileObject, disposition string) {
byteRange, err := parseByteRange(ctx.GetHeader("Range"), fileObject.ActualSize)
if err != nil {
ctx.Header("Content-Range", fmt.Sprintf("bytes */%d", fileObject.ActualSize))
ctx.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"message": err.Error()})
return
}
body, err := impl.StorageService.Open(ctx.Request.Context(), fileObject.ObjectKey, byteRange)
if err != nil {
runtimeinfo.RecordReadFailure()
ctx.JSON(http.StatusNotFound, gin.H{"message": "文件不存在"})
return
}
defer body.Close()
status := http.StatusOK
length := fileObject.ActualSize
if byteRange != nil {
status = http.StatusPartialContent
length = byteRange.Length()
ctx.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", byteRange.Start, byteRange.End, fileObject.ActualSize))
}
ctx.Header("Accept-Ranges", "bytes")
ctx.Header("Cache-Control", "no-store")
if fileObject.StorageETag != "" {
ctx.Header("ETag", fmt.Sprintf("\"%s\"", strings.ReplaceAll(fileObject.StorageETag, "\"", "")))
}
if fileObject.CompletedAt != nil {
ctx.Header("Last-Modified", fileObject.CompletedAt.UTC().Format(http.TimeFormat))
}
contentDisposition := mime.FormatMediaType(disposition, map[string]string{"filename": fileObject.OriginalName})
ctx.Header("Content-Disposition", contentDisposition)
ctx.DataFromReader(status, length, fileObject.ContentType, body, nil)
}
func normalizeDisposition(value, defaultValue string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "inline":
return "inline"
case "attachment":
return "attachment"
default:
return defaultValue
}
}

View File

@@ -3,6 +3,7 @@ package files
import (
"git.apinb.com/ops/files/internal/models"
"git.apinb.com/ops/files/internal/storage"
"time"
)
type InitUploadRequest struct {
@@ -13,18 +14,34 @@ type InitUploadRequest struct {
}
type InitUploadResponse struct {
FileID string `json:"file_id"`
ObjectKey string `json:"object_key"`
Upload storage.UploadInstruction `json:"upload"`
FileID string `json:"file_id"`
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"`
StorageETag string `json:"storage_etag"`
Status models.FileStatus `json:"status"`
}
type AccessInstruction struct {
FileID string `json:"file_id"`
URL string `json:"url"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
Disposition string `json:"disposition"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
type BatchAccessRequest struct {
FileIDs []string `json:"file_ids" binding:"required"`
Disposition string `json:"disposition"`
}
type SetVisibilityRequest struct {
Visibility models.FileVisibility `json:"visibility" binding:"required"`
}

View File

@@ -0,0 +1,39 @@
package files
import (
"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 SetVisibility(ctx *gin.Context) {
fileObject, ok := loadOwnedFile(ctx)
if !ok {
return
}
var request SetVisibilityRequest
if err := ctx.ShouldBindJSON(&request); err != nil ||
(request.Visibility != models.FileVisibilityPrivate && request.Visibility != models.FileVisibilityPublic) {
infra.Response.Error(ctx, fileerrors.ErrInvalidParameter)
return
}
if fileObject.Namespace != "visual" || fileObject.Status != models.FileStatusReady {
infra.Response.Error(ctx, fileerrors.ErrVisibilityNotAllowed)
return
}
result := impl.DBService.Model(&models.FileObject{}).
Where("id = ? AND status = ?", fileObject.ID, models.FileStatusReady).
Update("visibility", request.Visibility)
if result.Error != nil {
infra.Response.Error(ctx, fileerrors.ErrDatabaseOperation)
return
}
if result.RowsAffected != 1 {
infra.Response.Error(ctx, fileerrors.ErrUploadStatusConflict)
return
}
fileObject.Visibility = request.Visibility
infra.Response.Success(ctx, newFileResponse(fileObject))
}