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

@@ -1,21 +1,18 @@
package storage
package aliyun
import (
"time"
"git.apinb.com/ops/files/internal/config"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
)
type Client struct {
client *oss.Client
bucket string
publicBaseURL string
presignTTL time.Duration
client *oss.Client
bucket string
presignTTL int64
}
func New(cfg config.ObjectStorageConf) (*Client, error) {
func New(cfg config.AliyunStorageConf, accessTTLSeconds int64) (*Client, error) {
ossConfig := oss.LoadDefaultConfig().
WithRegion(cfg.Region).
WithEndpoint(cfg.Endpoint).
@@ -23,9 +20,12 @@ func New(cfg config.ObjectStorageConf) (*Client, error) {
WithSignatureVersion(oss.SignatureVersionV4)
return &Client{
client: oss.NewClient(ossConfig),
bucket: cfg.Bucket,
publicBaseURL: cfg.PublicBaseURL,
presignTTL: time.Duration(cfg.PresignTTLSeconds) * time.Second,
client: oss.NewClient(ossConfig),
bucket: cfg.Bucket,
presignTTL: accessTTLSeconds,
}, nil
}
func (c *Client) Provider() string { return "aliyun" }
func (c *Client) Container() string { return c.bucket }

View File

@@ -0,0 +1,99 @@
package aliyun
import (
"context"
"errors"
"fmt"
"io"
"strings"
"git.apinb.com/ops/files/internal/storage"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
)
// Open 返回对象存储中的只读内容流。
func (c *Client) Open(ctx context.Context, objectKey string, byteRange *storage.ByteRange) (io.ReadCloser, error) {
if strings.TrimSpace(objectKey) == "" {
return nil, fmt.Errorf("对象键不能为空")
}
request := &oss.GetObjectRequest{
Bucket: oss.Ptr(c.bucket),
Key: oss.Ptr(objectKey),
}
if byteRange != nil {
rangeHeader := fmt.Sprintf("bytes=%d-%d", byteRange.Start, byteRange.End)
request.Range = &rangeHeader
}
result, err := c.client.GetObject(ctx, request)
if err != nil {
return nil, fmt.Errorf("读取对象: %w", err)
}
if result == nil || result.Body == nil {
return nil, fmt.Errorf("对象内容为空")
}
return result.Body, nil
}
func (c *Client) Stat(ctx context.Context, objectKey string) (storage.ObjectInfo, error) {
if strings.TrimSpace(objectKey) == "" {
return storage.ObjectInfo{}, fmt.Errorf("对象键不能为空")
}
result, err := c.client.GetObjectMeta(ctx, &oss.GetObjectMetaRequest{
Bucket: oss.Ptr(c.bucket),
Key: oss.Ptr(objectKey),
})
if err != nil {
var serviceError *oss.ServiceError
if errors.As(err, &serviceError) && serviceError.Code == "NoSuchKey" {
return storage.ObjectInfo{}, storage.ErrObjectNotFound
}
return storage.ObjectInfo{}, fmt.Errorf("查询对象元数据: %w", err)
}
if result == nil {
return storage.ObjectInfo{}, fmt.Errorf("对象元数据为空")
}
if result.ContentLength < 0 {
return storage.ObjectInfo{}, fmt.Errorf("对象大小无效: %d", result.ContentLength)
}
if result.ETag == nil {
return storage.ObjectInfo{}, fmt.Errorf("对象 ETag 缺失")
}
etag := strings.TrimSpace(strings.Trim(strings.TrimSpace(*result.ETag), "\""))
if etag == "" {
return storage.ObjectInfo{}, fmt.Errorf("对象 ETag 无效")
}
if result.LastModified == nil || result.LastModified.IsZero() {
return storage.ObjectInfo{}, fmt.Errorf("对象最后修改时间缺失")
}
return storage.ObjectInfo{
Size: result.ContentLength,
StorageETag: etag,
LastModified: *result.LastModified,
}, nil
}
func (c *Client) Delete(ctx context.Context, objectKey string) error {
if strings.TrimSpace(objectKey) == "" {
return fmt.Errorf("对象键不能为空")
}
_, err := c.client.DeleteObject(ctx, &oss.DeleteObjectRequest{
Bucket: oss.Ptr(c.bucket),
Key: oss.Ptr(objectKey),
})
if err != nil {
return fmt.Errorf("删除对象: %w", err)
}
return nil
}
func (c *Client) Status(ctx context.Context) (storage.RuntimeStatus, error) {
_, err := c.client.ListObjectsV2(ctx, &oss.ListObjectsV2Request{Bucket: oss.Ptr(c.bucket), MaxKeys: 1})
if err != nil {
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container()}, fmt.Errorf("检查 OSS 存储状态: %w", err)
}
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Writable: true}, nil
}

View File

@@ -0,0 +1,37 @@
package aliyun
import (
"context"
"fmt"
"strings"
"time"
"git.apinb.com/ops/files/internal/storage"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
)
func (c *Client) PrepareUpload(ctx context.Context, request storage.UploadRequest) (storage.UploadInstruction, error) {
if strings.TrimSpace(request.ObjectKey) == "" {
return storage.UploadInstruction{}, fmt.Errorf("对象键不能为空")
}
if strings.TrimSpace(request.ContentType) == "" {
return storage.UploadInstruction{}, fmt.Errorf("内容类型不能为空")
}
result, err := c.client.Presign(ctx, &oss.PutObjectRequest{
Bucket: oss.Ptr(c.bucket),
Key: oss.Ptr(request.ObjectKey),
ContentType: oss.Ptr(request.ContentType),
ForbidOverwrite: oss.Ptr("true"),
}, oss.PresignExpires(time.Duration(c.presignTTL)*time.Second))
if err != nil {
return storage.UploadInstruction{}, fmt.Errorf("生成上传预签名: %w", err)
}
return storage.UploadInstruction{
Method: result.Method,
URL: result.URL,
Headers: result.SignedHeaders,
ExpiresAt: result.Expiration,
}, nil
}

View File

@@ -0,0 +1,77 @@
package storage
import (
"context"
"errors"
"io"
"time"
)
var (
ErrInsufficientSpace = errors.New("文件存储空间不足")
ErrUploadLocked = errors.New("文件正在上传")
ErrObjectNotFound = errors.New("文件对象不存在")
)
type UploadRequest struct {
FileID string
ObjectKey string
ContentType string
ExpectedSize int64
ExpiresAt time.Time
}
type UploadInstruction struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
ExpiresAt time.Time `json:"expires_at"`
}
type ByteRange struct {
Start int64
End int64
}
func (r ByteRange) Length() int64 {
return r.End - r.Start + 1
}
type ObjectInfo struct {
Size int64
StorageETag string
LastModified time.Time
}
type DiskStatus struct {
Path string `json:"path"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedPercent float64 `json:"used_percent"`
MinimumFreeBytes uint64 `json:"minimum_free_bytes"`
Level string `json:"level"`
}
type RuntimeStatus struct {
Provider string `json:"provider"`
Container string `json:"container"`
Writable bool `json:"writable"`
Disk *DiskStatus `json:"disk,omitempty"`
}
type Backend interface {
Provider() string
Container() string
PrepareUpload(context.Context, UploadRequest) (UploadInstruction, error)
Open(context.Context, string, *ByteRange) (io.ReadCloser, error)
Stat(context.Context, string) (ObjectInfo, error)
Delete(context.Context, string) error
Status(context.Context) (RuntimeStatus, error)
}
type UploadReceiver interface {
AcquireUpload(context.Context, string) (release func() error, err error)
ReceiveUpload(context.Context, UploadRequest, io.Reader) (ObjectInfo, error)
DiscardUpload(context.Context, string) error
}

View File

@@ -0,0 +1,21 @@
package factory
import (
"fmt"
"git.apinb.com/ops/files/internal/config"
"git.apinb.com/ops/files/internal/storage"
"git.apinb.com/ops/files/internal/storage/aliyun"
"git.apinb.com/ops/files/internal/storage/local"
)
func New(storageConfig config.StorageConf) (storage.Backend, error) {
switch storageConfig.Provider {
case "local":
return local.New(storageConfig)
case "aliyun":
return aliyun.New(storageConfig.Aliyun, storageConfig.AccessTTLSeconds)
default:
return nil, fmt.Errorf("不支持的文件存储类型: %s", storageConfig.Provider)
}
}

View File

@@ -0,0 +1,46 @@
package local
import (
"fmt"
"math"
"git.apinb.com/ops/files/internal/storage"
"github.com/shirou/gopsutil/v3/disk"
)
func (c *Client) diskStatus() (*storage.DiskStatus, error) {
usage, err := disk.Usage(c.rootPath)
if err != nil {
return nil, fmt.Errorf("读取本地存储磁盘容量: %w", err)
}
level := "normal"
if usage.Free < c.minimumFreeBytes {
level = "critical"
} else if c.minimumFreeBytes <= math.MaxUint64/2 && usage.Free < c.minimumFreeBytes*2 {
level = "warning"
}
return &storage.DiskStatus{
Path: c.rootPath,
TotalBytes: usage.Total,
UsedBytes: usage.Used,
FreeBytes: usage.Free,
UsedPercent: usage.UsedPercent,
MinimumFreeBytes: c.minimumFreeBytes,
Level: level,
}, nil
}
func (c *Client) ensureCapacity(expectedSize int64) error {
if expectedSize < 0 {
return fmt.Errorf("文件大小不能为负数")
}
diskStatus, err := c.diskStatus()
if err != nil {
return err
}
requested := uint64(expectedSize)
if requested > math.MaxUint64-c.minimumFreeBytes || diskStatus.FreeBytes < requested+c.minimumFreeBytes {
return storage.ErrInsufficientSpace
}
return nil
}

View File

@@ -0,0 +1,60 @@
package local
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.apinb.com/ops/files/internal/config"
)
const containerName = "local"
type Client struct {
rootPath string
objectsPath string
stagingPath string
locksPath string
externalBaseURL string
signingSecret string
accessTTLSeconds int64
minimumFreeBytes uint64
}
func New(storageConfig config.StorageConf) (*Client, error) {
rootPath, err := filepath.Abs(storageConfig.Local.RootPath)
if err != nil {
return nil, fmt.Errorf("解析本地存储根目录: %w", err)
}
if err := os.MkdirAll(rootPath, 0o750); err != nil {
return nil, fmt.Errorf("创建本地存储根目录: %w", err)
}
rootPath, err = filepath.EvalSymlinks(rootPath)
if err != nil {
return nil, fmt.Errorf("解析本地存储真实路径: %w", err)
}
client := &Client{
rootPath: filepath.Clean(rootPath),
objectsPath: filepath.Join(rootPath, "objects"),
stagingPath: filepath.Join(rootPath, ".staging"),
locksPath: filepath.Join(rootPath, ".locks"),
externalBaseURL: strings.TrimRight(storageConfig.ExternalBaseURL, "/"),
signingSecret: storageConfig.SigningSecret,
accessTTLSeconds: storageConfig.AccessTTLSeconds,
minimumFreeBytes: storageConfig.Local.MinimumFreeSpaceMB * 1024 * 1024,
}
for _, directory := range []string{client.objectsPath, client.stagingPath, client.locksPath} {
if err := client.ensureDirectory(directory); err != nil {
return nil, err
}
}
if _, err := client.Status(nil); err != nil {
return nil, err
}
return client, nil
}
func (c *Client) Provider() string { return "local" }
func (c *Client) Container() string { return containerName }

View File

@@ -0,0 +1,152 @@
package local
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.apinb.com/ops/files/internal/storage"
)
func (c *Client) Open(ctx context.Context, objectKey string, byteRange *storage.ByteRange) (io.ReadCloser, error) {
path, err := c.resolveObjectPath(objectKey)
if err != nil {
return nil, err
}
file, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, storage.ErrObjectNotFound
}
return nil, fmt.Errorf("打开本地文件: %w", err)
}
if byteRange == nil {
return &contextReadCloser{ctx: ctx, reader: file, closer: file}, nil
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
if byteRange.Start < 0 || byteRange.End < byteRange.Start || byteRange.End >= info.Size() {
_ = file.Close()
return nil, fmt.Errorf("文件读取范围无效")
}
if _, err := file.Seek(byteRange.Start, io.SeekStart); err != nil {
_ = file.Close()
return nil, err
}
return &contextReadCloser{ctx: ctx, reader: io.LimitReader(file, byteRange.Length()), closer: file}, nil
}
func (c *Client) Stat(ctx context.Context, objectKey string) (storage.ObjectInfo, error) {
path, err := c.resolveObjectPath(objectKey)
if err != nil {
return storage.ObjectInfo{}, err
}
file, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return storage.ObjectInfo{}, storage.ErrObjectNotFound
}
return storage.ObjectInfo{}, fmt.Errorf("打开本地文件: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return storage.ObjectInfo{}, err
}
hasher := sha256.New()
if _, err := io.Copy(hasher, &contextReader{ctx: ctx, reader: file}); err != nil {
return storage.ObjectInfo{}, fmt.Errorf("计算本地文件摘要: %w", err)
}
return storage.ObjectInfo{
Size: info.Size(),
StorageETag: "sha256:" + hex.EncodeToString(hasher.Sum(nil)),
LastModified: info.ModTime().UTC(),
}, nil
}
func (c *Client) Delete(_ context.Context, objectKey string) error {
path, err := c.resolveObjectPath(objectKey)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除本地文件: %w", err)
}
c.removeEmptyParents(filepath.Dir(path))
return nil
}
func (c *Client) removeEmptyParents(directory string) {
boundary := filepath.Clean(c.objectsPath)
for current := filepath.Clean(directory); current != boundary; current = filepath.Dir(current) {
relative, err := filepath.Rel(boundary, current)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return
}
if err := os.Remove(current); err != nil {
return
}
}
}
func (c *Client) Status(_ context.Context) (storage.RuntimeStatus, error) {
diskStatus, err := c.diskStatus()
if err != nil {
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container()}, err
}
probe, err := os.CreateTemp(c.stagingPath, ".write-probe-")
if err != nil {
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, fmt.Errorf("本地存储目录不可写: %w", err)
}
probePath := probe.Name()
removeProbe := func() { _ = os.Remove(probePath) }
defer removeProbe()
if err := probe.Chmod(0o640); err != nil {
_ = probe.Close()
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
}
if _, err := probe.Write([]byte("files-storage-probe")); err != nil {
_ = probe.Close()
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
}
if err := probe.Sync(); err != nil {
_ = probe.Close()
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
}
if err := probe.Close(); err != nil {
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
}
if err := os.Remove(probePath); err != nil {
return storage.RuntimeStatus{Provider: c.Provider(), Container: c.Container(), Disk: diskStatus}, err
}
return storage.RuntimeStatus{
Provider: c.Provider(),
Container: c.Container(),
Writable: diskStatus.Level != "critical",
Disk: diskStatus,
}, nil
}
type contextReadCloser struct {
ctx context.Context
reader io.Reader
closer io.Closer
}
func (r *contextReadCloser) Read(buffer []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.reader.Read(buffer)
}
func (r *contextReadCloser) Close() error { return r.closer.Close() }

View File

@@ -0,0 +1,79 @@
package local
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func (c *Client) resolveObjectPath(objectKey string) (string, error) {
if strings.TrimSpace(objectKey) == "" || filepath.IsAbs(objectKey) || strings.Contains(objectKey, "\\") {
return "", fmt.Errorf("对象键不是安全的相对路径")
}
for _, segment := range strings.Split(objectKey, "/") {
if segment == "" || segment == "." || segment == ".." {
return "", fmt.Errorf("对象键包含无效路径段")
}
}
target := filepath.Join(c.objectsPath, filepath.FromSlash(objectKey))
relative, err := filepath.Rel(c.objectsPath, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("对象路径超出存储目录")
}
if err := c.ensureNoSymlink(filepath.Dir(target), c.objectsPath); err != nil {
return "", err
}
return target, nil
}
func (c *Client) ensureDirectory(directory string) error {
if err := os.MkdirAll(directory, 0o750); err != nil {
return fmt.Errorf("创建本地存储目录 %s: %w", directory, err)
}
if err := c.ensureNoSymlink(directory, c.rootPath); err != nil {
return err
}
return os.Chmod(directory, 0o750)
}
func (c *Client) ensureNoSymlink(target, boundary string) error {
target = filepath.Clean(target)
boundary = filepath.Clean(boundary)
relative, err := filepath.Rel(boundary, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return fmt.Errorf("路径超出本地存储边界")
}
current := boundary
if relative == "." {
return rejectSymlink(current)
}
if err := rejectSymlink(current); err != nil {
return err
}
for _, segment := range strings.Split(relative, string(filepath.Separator)) {
current = filepath.Join(current, segment)
info, statErr := os.Lstat(current)
if os.IsNotExist(statErr) {
continue
}
if statErr != nil {
return fmt.Errorf("检查路径 %s: %w", current, statErr)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("本地存储路径不允许符号链接: %s", current)
}
}
return nil
}
func rejectSymlink(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("本地存储路径不允许符号链接: %s", path)
}
return nil
}

View File

@@ -0,0 +1,190 @@
package local
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.apinb.com/ops/files/internal/signing"
"git.apinb.com/ops/files/internal/storage"
)
func (c *Client) PrepareUpload(_ context.Context, request storage.UploadRequest) (storage.UploadInstruction, error) {
if err := c.ensureCapacity(request.ExpectedSize); err != nil {
return storage.UploadInstruction{}, err
}
signature := signing.SignUpload(c.signingSecret, request)
return storage.UploadInstruction{
Method: "PUT",
URL: fmt.Sprintf("%s/v1/local/uploads/%s?expires=%d&signature=%s",
c.externalBaseURL, request.FileID, request.ExpiresAt.Unix(), signature),
Headers: map[string]string{
"Content-Type": request.ContentType,
"Content-Length": fmt.Sprintf("%d", request.ExpectedSize),
},
ExpiresAt: request.ExpiresAt,
}, nil
}
func (c *Client) AcquireUpload(ctx context.Context, fileID string) (func() error, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if !validFileID(fileID) {
return nil, fmt.Errorf("文件标识无效")
}
lockPath := filepath.Join(c.locksPath, fileID+".lock")
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
if os.IsExist(err) {
return nil, storage.ErrUploadLocked
}
return nil, fmt.Errorf("获取上传锁: %w", err)
}
return func() error {
closeErr := lockFile.Close()
removeErr := os.Remove(lockPath)
if os.IsNotExist(removeErr) {
removeErr = nil
}
if closeErr != nil {
return closeErr
}
return removeErr
}, nil
}
func (c *Client) ReceiveUpload(ctx context.Context, request storage.UploadRequest, source io.Reader) (storage.ObjectInfo, error) {
if err := c.ensureCapacity(request.ExpectedSize); err != nil {
return storage.ObjectInfo{}, err
}
targetPath, err := c.resolveObjectPath(request.ObjectKey)
if err != nil {
return storage.ObjectInfo{}, err
}
if err := c.ensureDirectory(filepath.Dir(targetPath)); err != nil {
return storage.ObjectInfo{}, err
}
if existing, statErr := c.Stat(ctx, request.ObjectKey); statErr == nil {
if existing.Size != request.ExpectedSize {
return storage.ObjectInfo{}, fmt.Errorf("已存在文件的大小与上传请求不一致")
}
return existing, nil
} else if !errors.Is(statErr, storage.ErrObjectNotFound) {
return storage.ObjectInfo{}, statErr
}
nonce, err := signing.UploadNonce(c.signingSecret, request)
if err != nil {
return storage.ObjectInfo{}, err
}
stagingPath := filepath.Join(c.stagingPath, request.FileID+"."+nonce+".part")
staging, err := os.OpenFile(stagingPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
return storage.ObjectInfo{}, fmt.Errorf("创建上传暂存文件: %w", err)
}
committed := false
defer func() {
_ = staging.Close()
if !committed {
_ = os.Remove(stagingPath)
}
}()
hasher := sha256.New()
written, err := io.Copy(io.MultiWriter(staging, hasher), io.LimitReader(&contextReader{ctx: ctx, reader: source}, request.ExpectedSize+1))
if err != nil {
return storage.ObjectInfo{}, fmt.Errorf("写入上传暂存文件: %w", err)
}
if written != request.ExpectedSize {
return storage.ObjectInfo{}, fmt.Errorf("上传文件大小不匹配: 期望 %d实际 %d", request.ExpectedSize, written)
}
if err := staging.Sync(); err != nil {
return storage.ObjectInfo{}, fmt.Errorf("同步上传暂存文件: %w", err)
}
if err := staging.Close(); err != nil {
return storage.ObjectInfo{}, fmt.Errorf("关闭上传暂存文件: %w", err)
}
if err := os.Link(stagingPath, targetPath); err != nil {
return storage.ObjectInfo{}, fmt.Errorf("提交上传文件: %w", err)
}
if err := os.Chmod(targetPath, 0o640); err != nil {
_ = os.Remove(targetPath)
return storage.ObjectInfo{}, fmt.Errorf("设置上传文件权限: %w", err)
}
if err := os.Remove(stagingPath); err != nil {
_ = os.Remove(targetPath)
return storage.ObjectInfo{}, fmt.Errorf("清理上传暂存文件: %w", err)
}
if err := syncDirectory(filepath.Dir(targetPath)); err != nil {
_ = os.Remove(targetPath)
return storage.ObjectInfo{}, err
}
committed = true
return storage.ObjectInfo{
Size: written,
StorageETag: "sha256:" + hex.EncodeToString(hasher.Sum(nil)),
LastModified: time.Now().UTC(),
}, nil
}
func (c *Client) DiscardUpload(_ context.Context, fileID string) error {
if !validFileID(fileID) {
return fmt.Errorf("文件标识无效")
}
entries, err := os.ReadDir(c.stagingPath)
if err != nil {
return err
}
prefix := fileID + "."
for _, entry := range entries {
if !entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) && strings.HasSuffix(entry.Name(), ".part") {
if err := os.Remove(filepath.Join(c.stagingPath, entry.Name())); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}
type contextReader struct {
ctx context.Context
reader io.Reader
}
func (r *contextReader) Read(buffer []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.reader.Read(buffer)
}
func validFileID(fileID string) bool {
if fileID == "" {
return false
}
for _, character := range fileID {
if (character < '0' || character > '9') && (character < 'A' || character > 'Z') && (character < 'a' || character > 'z') && character != '-' {
return false
}
}
return true
}
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return fmt.Errorf("打开目录进行同步: %w", err)
}
defer directory.Close()
if err := directory.Sync(); err != nil {
return fmt.Errorf("同步文件目录: %w", err)
}
return nil
}

View File

@@ -1,76 +0,0 @@
package storage
import (
"context"
"fmt"
"strings"
"time"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
)
type ObjectInfo struct {
Size int64
ETag string
LastModified time.Time
}
func (c *Client) Stat(ctx context.Context, objectKey string) (ObjectInfo, error) {
if strings.TrimSpace(objectKey) == "" {
return ObjectInfo{}, fmt.Errorf("对象键不能为空")
}
result, err := c.client.GetObjectMeta(ctx, &oss.GetObjectMetaRequest{
Bucket: oss.Ptr(c.bucket),
Key: oss.Ptr(objectKey),
})
if err != nil {
return ObjectInfo{}, fmt.Errorf("查询对象元数据: %w", err)
}
if result == nil {
return ObjectInfo{}, fmt.Errorf("对象元数据为空")
}
if result.ContentLength < 0 {
return ObjectInfo{}, fmt.Errorf("对象大小无效: %d", result.ContentLength)
}
if result.ETag == nil {
return ObjectInfo{}, fmt.Errorf("对象 ETag 缺失")
}
etag := strings.TrimSpace(strings.Trim(strings.TrimSpace(*result.ETag), "\""))
if etag == "" {
return ObjectInfo{}, fmt.Errorf("对象 ETag 无效")
}
if result.LastModified == nil || result.LastModified.IsZero() {
return ObjectInfo{}, fmt.Errorf("对象最后修改时间缺失")
}
return ObjectInfo{
Size: result.ContentLength,
ETag: etag,
LastModified: *result.LastModified,
}, nil
}
func (c *Client) Delete(ctx context.Context, objectKey string) error {
if strings.TrimSpace(objectKey) == "" {
return fmt.Errorf("对象键不能为空")
}
_, err := c.client.DeleteObject(ctx, &oss.DeleteObjectRequest{
Bucket: oss.Ptr(c.bucket),
Key: oss.Ptr(objectKey),
})
if err != nil {
return fmt.Errorf("删除对象: %w", err)
}
return nil
}
func (c *Client) PublicURL(objectKey string) string {
return strings.TrimRight(c.publicBaseURL, "/") + "/" + strings.TrimLeft(objectKey, "/")
}
func (c *Client) Bucket() string {
return c.bucket
}

View File

@@ -1,43 +0,0 @@
package storage
import (
"context"
"fmt"
"strings"
"time"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
)
type UploadInstruction struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
ExpiresAt time.Time `json:"expires_at"`
}
func (c *Client) PresignPut(ctx context.Context, objectKey, contentType string) (UploadInstruction, error) {
if strings.TrimSpace(objectKey) == "" {
return UploadInstruction{}, fmt.Errorf("对象键不能为空")
}
if strings.TrimSpace(contentType) == "" {
return UploadInstruction{}, fmt.Errorf("内容类型不能为空")
}
result, err := c.client.Presign(ctx, &oss.PutObjectRequest{
Bucket: oss.Ptr(c.bucket),
Key: oss.Ptr(objectKey),
ContentType: oss.Ptr(contentType),
ForbidOverwrite: oss.Ptr("true"),
}, oss.PresignExpires(c.presignTTL))
if err != nil {
return UploadInstruction{}, fmt.Errorf("生成上传预签名: %w", err)
}
return UploadInstruction{
Method: result.Method,
URL: result.URL,
Headers: result.SignedHeaders,
ExpiresAt: result.Expiration,
}, nil
}