Files
2026-07-14 10:24:10 +08:00

870 lines
28 KiB
Go
Raw Permalink 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 storage
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"go.uber.org/zap"
)
// Storage 存储接口
type Storage interface {
Upload(file *multipart.FileHeader, subdir string) (string, error)
UploadFromBytes(data []byte, filename, subdir string) (string, error)
GetURL(path string) string
Delete(path string) error
Get(path string) ([]byte, error)
// Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil)
// 未初始化、路径非法/穿越、后端错误返回 (false, err)。
Exists(path string) (bool, error)
}
var (
// ErrStorageNotInitialized storage 未初始化。
ErrStorageNotInitialized = errors.New("storage not initialized")
// ErrPathTraversal 路径穿越被拒绝(C4a)。Delete/Get/Exists/Upload 的相对路径
// 含 `..` 或绝对路径、逃逸根目录时返回。
ErrPathTraversal = errors.New("path traversal detected")
// ErrInvalidPath 路径非法(空、含 NUL 等)。
ErrInvalidPath = errors.New("invalid path")
// ErrInvalidFile 上传文件参数非法,例如 nil *multipart.FileHeader。
ErrInvalidFile = errors.New("invalid file")
// ErrUploadTooLarge 上传声明大小或实际字节数超过 MaxSizeBytesP0)。客户端声明的 file.Size
// 不可信,故除前置校验外,拷贝阶段也按实际字节封顶,防止声明小体积却流式发送大 body 撑爆磁盘/OSS。
ErrUploadTooLarge = errors.New("upload exceeds max size")
// ErrReadTooLarge Get 读取内容超过 maxReadBytes 上限(C4c)。与 ErrUploadTooLarge 区分:
// 前者是读操作的超限,后者是上传操作的超限。
ErrReadTooLarge = errors.New("read exceeds max size")
)
const (
// defaultMaxReadBytes Get 默认读取上限(100MB),防止全量读入内存 OOM(C4c)。
defaultMaxReadBytes int64 = 100 * 1024 * 1024
// mimeSniffPrefixLen http.DetectContentType 最多嗅探 512 字节。
mimeSniffPrefixLen = 512
)
// resolveMaxRead 解析 Get 读取上限:n<0 不限,n==0 用默认,n>0 用 n。
func resolveMaxRead(n int64) int64 {
if n < 0 {
return -1
}
if n == 0 {
return defaultMaxReadBytes
}
return n
}
// validateUploadSize 校验上传文件大小(C4b)。MaxSizeBytes<=0 表示不限。
//
// 注意:此处的 size 来自客户端声明(multipart.FileHeader.Size),仅作前置快速拒绝,
// 不可信——攻击者可声明小体积却流式发送大 body。真正的落盘/上传封顶由 enforceUploadSize
// 在拷贝阶段按实际字节数二次把关(P0)。
func validateUploadSize(p config.UploadPolicy, size int64) error {
if p.MaxSizeBytes > 0 && size > p.MaxSizeBytes {
return fmt.Errorf("文件大小 %d 超过上限 %d: %w", size, p.MaxSizeBytes, ErrUploadTooLarge)
}
return nil
}
// enforceMaxReader 包装底层 reader,实际读取累计超过 max 字节即返回 ErrUploadTooLargeP0)。
// max<=0 表示不限。用于上传拷贝阶段的真实大小封顶,弥补客户端声明 Size 不可信。
type enforceMaxReader struct {
src io.Reader
max int64
count int64
}
func (r *enforceMaxReader) Read(p []byte) (int, error) {
if r.max > 0 && r.count >= r.max {
// 上限内的字节已全部读出:用独立探针读 1 字节判断源是否还有数据,有则超限。
var probe [1]byte
if n, _ := r.src.Read(probe[:]); n > 0 {
return 0, ErrUploadTooLarge
}
// 源已到 EOF,实际大小恰好等于上限,放行结束。
return 0, io.EOF
}
n, err := r.src.Read(p)
r.count += int64(n)
if r.max > 0 && r.count > r.max {
return 0, ErrUploadTooLarge
}
return n, err
}
// enforceUploadSize 若策略配置了 MaxSizeBytes,则包装 src 在拷贝阶段实测封顶(P0)。
// 未配置上限时原样返回,零开销。
func enforceUploadSize(p config.UploadPolicy, src io.Reader) io.Reader {
if p.MaxSizeBytes <= 0 {
return src
}
return &enforceMaxReader{src: src, max: p.MaxSizeBytes}
}
// validateUploadExt 校验上传文件扩展名(C4b)。AllowedExts 为空表示不限。
func validateUploadExt(p config.UploadPolicy, filename string) error {
if len(p.AllowedExts) == 0 {
return nil
}
ext := strings.ToLower(filepath.Ext(filename))
if !containsString(p.AllowedExts, ext) {
return fmt.Errorf("扩展名 %s 不在白名单: %w", ext, ErrInvalidPath)
}
return nil
}
// sniffUploadMIME 嗅探 src 前 512 字节并按 AllowedMIMEs 校验(C4b)。
// 返回的 reader 已拼回已读头部,后续可继续读到完整内容。
// AllowedMIMEs 为空时直接返回原 src 不嗅探。
func sniffUploadMIME(p config.UploadPolicy, src io.Reader) (io.Reader, error) {
if len(p.AllowedMIMEs) == 0 {
return src, nil
}
head := make([]byte, mimeSniffPrefixLen)
n, err := io.ReadFull(src, head)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return nil, fmt.Errorf("读取文件头失败: %w", err)
}
// http.DetectContentType 可能返回 "text/plain; charset=utf-8"
// 与白名单 "text/plain" 比较时取主类型(分号前)。
detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(head[:n]), ";")[0]))
allowed := make([]string, len(p.AllowedMIMEs))
for i, m := range p.AllowedMIMEs {
allowed[i] = strings.ToLower(strings.TrimSpace(strings.Split(m, ";")[0]))
}
if !containsString(allowed, detected) {
return nil, fmt.Errorf("文件类型 %s 不在白名单: %w", detected, ErrInvalidPath)
}
return io.MultiReader(bytes.NewReader(head[:n]), src), nil
}
func containsString(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
// uniqueFilename 生成带随机后缀的唯一文件名,避免同一纳秒内并发上传导致文件名冲突。
// 格式: <unixNano>-<8字节随机hex>.<ext>
func uniqueFilename(now time.Time, ext string) string {
randBytes := make([]byte, 8)
// crypto/rand 失败极少见;退化为全零也能由纳秒时间戳保证基本唯一性
_, _ = rand.Read(randBytes)
return fmt.Sprintf("%d-%x%s", now.UnixNano(), randBytes, ext)
}
// sanitizeObjectKey 净化 OSS object keyC4a)。OSS key 为扁平字符串无 FS 穿越语义,
// 但拒绝含 `..`、绝对路径、NUL、空值等可疑输入,防止 key 注入与越权访问。
// 同时将 Windows 反斜杠归一化为 OSS 规范的正斜杠,保证跨平台 key 一致。
func sanitizeObjectKey(key string) (string, error) {
if key == "" || strings.ContainsRune(key, 0) {
return "", ErrInvalidPath
}
// 归一化 Windows 反斜杠为正斜杠(OSS key 规范分隔符),保证 Windows/Linux 部署 key 一致。
key = strings.ReplaceAll(key, "\\", "/")
if strings.Contains(key, "..") {
return "", ErrPathTraversal
}
// 框架生成的 key 不以 / 开头;拒绝绝对路径形式避免歧义。
if strings.HasPrefix(key, "/") {
return "", ErrPathTraversal
}
// 规范化多余分隔符,不改变合法 key 语义。
return path.Clean(key), nil
}
func publicURL(baseURL, key string) string {
cleanKey, err := sanitizeObjectKey(key)
if err != nil {
return ""
}
return strings.TrimRight(baseURL, "/") + "/" + cleanKey
}
// LocalStorage 本地存储
type LocalStorage struct {
rootAbs string // 绝对根路径(已 Clean),前缀锚定用(C4a)
baseURL string
policy config.UploadPolicy
maxReadBytes int64
}
// NewLocalStorage 创建本地存储实例
//
// 安全约束:Path 指向的根目录应为框架独占目录,不与用户可控内容混用。
// safeJoin 已防 `..` 路径穿越,但若根目录内已存在指向外部的符号链接,
// Get 会跟随 symlink 读到根外内容——需保证攻击者无法在根目录内创建 symlink。
// cfg 为 nil、Path 无法解析或根目录本身是 symlink 时,实例 fail-closed
// 后续文件操作返回 ErrStorageNotInitialized。
func NewLocalStorage(cfg *config.LocalStorageConfig) *LocalStorage {
if cfg == nil {
return &LocalStorage{maxReadBytes: resolveMaxRead(0)}
}
// 用绝对路径作根锚定,避免相对路径 + `..` 组合绕过前缀校验(C4a)。
var rootAbs string
abs, err := filepath.Abs(cfg.Path)
if err != nil {
// P1 #20Abs 失败(如 os.Getwd 失败)不再回退相对 cfg.Path——相对根会使 safeJoin
// 前缀校验基于相对路径、削弱穿越防御。改为 fail-closed:置空 rootAbs
// safeJoin 据此拒绝一切操作,并记录错误,避免"看似可用实则防御失效"的本地存储。
logger.Error("storage: 解析存储根目录绝对路径失败,本地存储将不可用(fail-closed",
zap.String("path", cfg.Path), zap.Error(err))
rootAbs = ""
} else {
rootAbs = filepath.Clean(abs)
}
if rootAbs != "" {
if info, err := os.Lstat(rootAbs); err == nil && info.Mode()&os.ModeSymlink != 0 {
logger.Error("storage: local storage root must not be a symlink", zap.String("path", rootAbs))
rootAbs = ""
}
}
return &LocalStorage{
rootAbs: rootAbs,
baseURL: cfg.BaseURL,
policy: cfg.Upload,
maxReadBytes: resolveMaxRead(cfg.MaxReadBytes),
}
}
// safeJoin 将根路径与相对片段拼接为绝对路径,并以前缀锚定拒绝穿越(C4a)。
// 任何片段为绝对路径或含 NUL、最终路径逃逸 rootAbs 时返回错误。
func (s *LocalStorage) safeJoin(parts ...string) (string, error) {
// P1 #20rootAbs 为空表示构造时 Abs 失败,fail-closed 拒绝所有路径操作。
if s.rootAbs == "" {
return "", ErrStorageNotInitialized
}
for _, p := range parts {
if filepath.IsAbs(p) {
return "", ErrPathTraversal
}
if strings.ContainsRune(p, 0) {
return "", ErrInvalidPath
}
}
joined := filepath.Join(append([]string{s.rootAbs}, parts...)...)
if joined == s.rootAbs || !strings.HasPrefix(joined, s.rootAbs+string(os.PathSeparator)) {
return "", ErrPathTraversal
}
return joined, nil
}
// Upload 上传文件
func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (ret string, err error) {
if file == nil {
return "", ErrInvalidFile
}
// 上传安全策略校验(大小 / 扩展名);file.Size 由 multipart 解析时填,无需打开文件(C4b)。
if err := validateUploadSize(s.policy, file.Size); err != nil {
return "", err
}
if err := validateUploadExt(s.policy, file.Filename); err != nil {
return "", err
}
// 生成存储路径: /年/月/日/文件名
now := time.Now()
datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day())
relativePath := filepath.Join(subdir, datePath)
// 确保目录存在,且未逃逸根目录(C4a:subdir 含 `..` 会被 safeJoin 拒绝)
fullPath, err := s.safeJoin(relativePath)
if err != nil {
logger.Warn("上传路径被拒绝", zap.String("subdir", subdir), zap.Error(err))
return "", err
}
if err := os.MkdirAll(fullPath, 0750); err != nil {
logger.Error("创建目录失败", zap.Error(err), zap.String("path", fullPath))
return "", fmt.Errorf("创建目录失败: %w", err)
}
// 生成唯一文件名(服务端随机,可信)
ext := filepath.Ext(file.Filename)
filename := uniqueFilename(now, ext)
dst := filepath.Join(fullPath, filename)
// 打开源文件
src, err := file.Open()
if err != nil {
return "", fmt.Errorf("打开文件失败: %w", err)
}
defer src.Close()
// MIME 嗅探(如配置 AllowedMIMEs),嗅探后拼回头部
var srcReader io.Reader = src
srcReader, err = sniffUploadMIME(s.policy, srcReader)
if err != nil {
return "", err
}
// 创建目标文件
// #nosec G304 -- dst 由 safeJoin 净化后的 fullPath 与服务端随机生成的 filename 拼成,路径已防穿越
dstFile, err := os.Create(dst)
if err != nil {
return "", fmt.Errorf("创建文件失败: %w", err)
}
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
ret = ""
_ = os.Remove(dst)
}
}()
// 复制文件内容。按策略实测封顶(P0):超限即报错并清理已落盘的部分文件。
if _, err := io.Copy(dstFile, enforceUploadSize(s.policy, srcReader)); err != nil {
return "", fmt.Errorf("保存文件失败: %w", err)
}
// 返回相对路径
relativeFilePath := filepath.Join(relativePath, filename)
// 统一使用正斜杠
relativeFilePath = strings.ReplaceAll(relativeFilePath, "\\", "/")
logger.Info("文件上传成功", zap.String("path", relativeFilePath))
return relativeFilePath, nil
}
// UploadFromBytes 从字节数组上传文件
func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (ret string, err error) {
// 上传安全策略校验(C4b
if err := validateUploadSize(s.policy, int64(len(data))); err != nil {
return "", err
}
if err := validateUploadExt(s.policy, filename); err != nil {
return "", err
}
// 生成存储路径: /年/月/日/文件名
now := time.Now()
datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day())
relativePath := filepath.Join(subdir, datePath)
// 确保目录存在,且未逃逸根目录(C4a)
fullPath, err := s.safeJoin(relativePath)
if err != nil {
logger.Warn("上传路径被拒绝", zap.String("subdir", subdir), zap.Error(err))
return "", err
}
if err := os.MkdirAll(fullPath, 0750); err != nil {
logger.Error("创建目录失败", zap.Error(err), zap.String("path", fullPath))
return "", fmt.Errorf("创建目录失败: %w", err)
}
// 生成唯一文件名(如果未提供扩展名,添加 .bin)
ext := filepath.Ext(filename)
if ext == "" {
ext = ".bin"
}
fname := uniqueFilename(now, ext)
dst := filepath.Join(fullPath, fname)
// MIME 嗅探(如配置 AllowedMIMEs
var srcReader io.Reader = bytes.NewReader(data)
srcReader, err = sniffUploadMIME(s.policy, srcReader)
if err != nil {
return "", err
}
// 创建目标文件
// #nosec G304 -- dst 由 safeJoin 净化后的 fullPath 与服务端随机生成的 filename 拼成,路径已防穿越
dstFile, err := os.Create(dst)
if err != nil {
return "", fmt.Errorf("创建文件失败: %w", err)
}
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
ret = ""
_ = os.Remove(dst)
}
}()
// 写入文件内容
if _, err := io.Copy(dstFile, srcReader); err != nil {
return "", fmt.Errorf("保存文件失败: %w", err)
}
// 返回相对路径
relativeFilePath := filepath.Join(relativePath, fname)
// 统一使用正斜杠
relativeFilePath = strings.ReplaceAll(relativeFilePath, "\\", "/")
logger.Info("文件上传成功", zap.String("path", relativeFilePath))
return relativeFilePath, nil
}
// GetURL 获取文件访问 URL
func (s *LocalStorage) GetURL(path string) string {
return publicURL(s.baseURL, path)
}
// Delete 删除文件
func (s *LocalStorage) Delete(p string) error {
fullPath, err := s.safeJoin(p)
if err != nil {
logger.Warn("删除路径被拒绝", zap.String("path", p), zap.Error(err))
return err
}
if err := os.Remove(fullPath); err != nil {
logger.Error("删除文件失败", zap.Error(err), zap.String("path", fullPath))
return fmt.Errorf("删除文件失败: %w", err)
}
logger.Info("文件删除成功", zap.String("path", p))
return nil
}
// Get 获取文件内容。读取受 maxReadBytes 封顶,防止全量读入内存 OOM(C4c)。
func (s *LocalStorage) Get(p string) ([]byte, error) {
fullPath, err := s.safeJoin(p)
if err != nil {
logger.Warn("读取路径被拒绝", zap.String("path", p), zap.Error(err))
return nil, err
}
// #nosec G304 -- fullPath 经 safeJoin 前缀锚定净化,已防穿越
f, err := os.Open(fullPath)
if err != nil {
logger.Error("读取文件失败", zap.Error(err), zap.String("path", fullPath))
return nil, fmt.Errorf("读取文件失败: %w", err)
}
defer f.Close()
var reader io.Reader = f
if s.maxReadBytes > 0 {
// 多读 1 字节用于判断是否超限
reader = io.LimitReader(f, s.maxReadBytes+1)
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("读取文件内容失败: %w", err)
}
if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes {
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge)
}
return data, nil
}
// Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil)
// 路径非法/穿越返回 (false, err);其他 Stat 错误返回 (false, err)。
func (s *LocalStorage) Exists(p string) (bool, error) {
fullPath, err := s.safeJoin(p)
if err != nil {
return false, err
}
if _, err := os.Stat(fullPath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
// OSSStorage OSS 存储
type OSSStorage struct {
client *oss.Client
bucket *oss.Bucket
endpoint string
bucketName string
baseURL string
policy config.UploadPolicy
maxReadBytes int64
}
// NewOSSStorage 创建 OSS 存储实例
func NewOSSStorage(cfg *config.OSSStorageConfig) (*OSSStorage, error) {
if cfg == nil {
return nil, ErrStorageNotInitialized
}
client, err := oss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
return nil, fmt.Errorf("创建 OSS 客户端失败: %w", err)
}
bucket, err := client.Bucket(cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("获取 OSS Bucket 失败: %w", err)
}
return &OSSStorage{
client: client,
bucket: bucket,
endpoint: cfg.Endpoint,
bucketName: cfg.Bucket,
baseURL: cfg.BaseURL,
policy: cfg.Upload,
maxReadBytes: resolveMaxRead(cfg.MaxReadBytes),
}, nil
}
// Upload 上传文件到 OSS
func (s *OSSStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
if file == nil {
return "", ErrInvalidFile
}
// 上传安全策略校验(C4b
if err := validateUploadSize(s.policy, file.Size); err != nil {
return "", err
}
if err := validateUploadExt(s.policy, file.Filename); err != nil {
return "", err
}
// 生成存储路径: /年/月/日/文件名
now := time.Now()
datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day())
ext := filepath.Ext(file.Filename)
// OSS object key 规范用正斜杠;用 path.JoinPOSIX)而非 filepath.Join
// 避免 Windows 产反斜杠导致跨平台 key 不一致。
rawKey := path.Join(filepath.ToSlash(subdir), datePath, uniqueFilename(now, ext))
// 净化 object key,拒绝含 `..` 的 subdirC4a key 注入)
objectKey, err := sanitizeObjectKey(rawKey)
if err != nil {
logger.Warn("OSS 上传 key 被拒绝", zap.String("subdir", subdir), zap.Error(err))
return "", err
}
// 打开源文件
src, err := file.Open()
if err != nil {
return "", fmt.Errorf("打开文件失败: %w", err)
}
defer src.Close()
// MIME 嗅探(如配置)
var srcReader io.Reader = src
srcReader, err = sniffUploadMIME(s.policy, srcReader)
if err != nil {
return "", err
}
// 上传到 OSS。按策略实测封顶(P0):超限时 PutObject 读到 ErrUploadTooLarge 而失败,
// 简单 PutObject 语义下对象不会被提交,无需额外清理。
if err := s.bucket.PutObject(objectKey, enforceUploadSize(s.policy, srcReader)); err != nil {
logger.Error("OSS 上传失败", zap.Error(err), zap.String("key", objectKey))
return "", fmt.Errorf("OSS 上传失败: %w", err)
}
logger.Info("OSS 文件上传成功", zap.String("key", objectKey))
return objectKey, nil
}
// UploadFromBytes 从字节数组上传文件到 OSS
func (s *OSSStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
// 上传安全策略校验(C4b
if err := validateUploadSize(s.policy, int64(len(data))); err != nil {
return "", err
}
if err := validateUploadExt(s.policy, filename); err != nil {
return "", err
}
now := time.Now()
datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day())
ext := filepath.Ext(filename)
if ext == "" {
ext = ".bin"
}
// OSS object key 规范用正斜杠;用 path.JoinPOSIX)而非 filepath.Join
// 避免 Windows 产反斜杠导致跨平台 key 不一致。
rawKey := path.Join(filepath.ToSlash(subdir), datePath, uniqueFilename(now, ext))
objectKey, err := sanitizeObjectKey(rawKey)
if err != nil {
logger.Warn("OSS 上传 key 被拒绝", zap.String("subdir", subdir), zap.Error(err))
return "", err
}
// MIME 嗅探(如配置)
var srcReader io.Reader = bytes.NewReader(data)
srcReader, err = sniffUploadMIME(s.policy, srcReader)
if err != nil {
return "", err
}
// 上传到 OSS
if err := s.bucket.PutObject(objectKey, srcReader); err != nil {
logger.Error("OSS 上传失败", zap.Error(err), zap.String("key", objectKey))
return "", fmt.Errorf("OSS 上传失败: %w", err)
}
logger.Info("OSS 文件上传成功", zap.String("key", objectKey))
return objectKey, nil
}
// GetURL 获取文件访问 URL
func (s *OSSStorage) GetURL(path string) string {
cleanKey, err := sanitizeObjectKey(path)
if err != nil {
return ""
}
if s.baseURL != "" {
return strings.TrimRight(s.baseURL, "/") + "/" + cleanKey
}
return fmt.Sprintf("https://%s.%s/%s", s.bucketName, s.endpoint, cleanKey)
}
// GetSignedURL 获取带签名的临时访问 URL(用于私有文件)
func (s *OSSStorage) GetSignedURL(path string, expire time.Duration) (string, error) {
key, err := sanitizeObjectKey(path)
if err != nil {
return "", err
}
return s.bucket.SignURL(key, oss.HTTPGet, int64(expire.Seconds()))
}
// Delete 删除 OSS 文件
func (s *OSSStorage) Delete(p string) error {
key, err := sanitizeObjectKey(p)
if err != nil {
return err
}
if err := s.bucket.DeleteObject(key); err != nil {
logger.Error("OSS 删除失败", zap.Error(err), zap.String("key", key))
return fmt.Errorf("OSS 删除失败: %w", err)
}
logger.Info("OSS 文件删除成功", zap.String("key", key))
return nil
}
// Get 获取 OSS 文件内容。读取受 maxReadBytes 封顶,防止 OOMC4c)。
func (s *OSSStorage) Get(p string) ([]byte, error) {
key, err := sanitizeObjectKey(p)
if err != nil {
return nil, err
}
body, err := s.bucket.GetObject(key)
if err != nil {
logger.Error("OSS 读取失败", zap.Error(err), zap.String("key", key))
return nil, fmt.Errorf("OSS 读取失败: %w", err)
}
defer body.Close()
var reader io.Reader = body
if s.maxReadBytes > 0 {
reader = io.LimitReader(body, s.maxReadBytes+1)
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("读取 OSS 文件内容失败: %w", err)
}
if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes {
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge)
}
return data, nil
}
// Exists 检查 OSS 文件是否存在。存在返回 (true, nil)object 不存在返回 (false, nil)
// key 非法/穿越返回 (false, err);鉴权/网络/服务端错误返回 (false, err)。
func (s *OSSStorage) Exists(p string) (bool, error) {
key, err := sanitizeObjectKey(p)
if err != nil {
return false, err
}
if _, err := s.bucket.GetObjectMeta(key); err != nil {
// OSS object 不存在(404 / NoSuchKey)不是错误,返回 (false, nil)。
var se *oss.ServiceError
if errors.As(err, &se) && (se.StatusCode == http.StatusNotFound || se.Code == "NoSuchKey") {
return false, nil
}
return false, err
}
return true, nil
}
// StorageManager 存储管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultStorage 全局默认 + 包级 facade 代理,支持多实例与测试注入。
type StorageManager struct {
mu sync.Mutex
cfg *config.StorageConfig
current Storage
}
// DefaultStorage 默认存储管理器,包级 facade 代理到它。
//
// 主线A 修复:改用 atomic.Pointer 保护读写,消除原裸指针置换(SetDefaultStorageManager
// 与 facade 无锁读之间的数据竞争,与 config.defaultManager / database.DefaultRedis 对齐。
// 类型由 *StorageManager 变更为 atomic.Pointer[StorageManager]breaking:下游若直接
// 调用 DefaultStorage.Init 等方法需改用 Init 等 facade,或 DefaultStorage.Load().Init)。
var DefaultStorage atomic.Pointer[StorageManager]
func init() {
DefaultStorage.Store(NewStorageManager())
}
// NewStorageManager 创建存储管理器实例。
func NewStorageManager() *StorageManager { return &StorageManager{} }
// SetDefaultStorageManager 提升指定 StorageManager 为全局默认。并发安全(atomic.Store)。
func SetDefaultStorageManager(m *StorageManager) {
if m != nil {
DefaultStorage.Store(m)
}
}
// SwapDefaultStorageManager 将指定 StorageManager 置为全局默认,并返回被替换的旧 Manager。
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存(照
// SwapDefaultRedisManager / database.SwapDefaultManager 模式)。nil 被忽略,返回当前默认。
func SwapDefaultStorageManager(m *StorageManager) *StorageManager {
if m == nil {
return DefaultStorage.Load()
}
return DefaultStorage.Swap(m)
}
// Init 初始化存储
func (m *StorageManager) Init(cfg *config.StorageConfig) error {
if cfg == nil {
return ErrStorageNotInitialized
}
m.mu.Lock()
defer m.mu.Unlock()
var s Storage
switch cfg.Driver {
case "local":
s = NewLocalStorage(&cfg.Local)
logger.Info("使用本地存储", zap.String("path", cfg.Local.Path))
case "oss":
ossStorage, err := NewOSSStorage(&cfg.OSS)
if err != nil {
return err
}
s = ossStorage
logger.Info("使用 OSS 存储", zap.String("bucket", cfg.OSS.Bucket))
default:
return fmt.Errorf("不支持的存储驱动: %s", cfg.Driver)
}
m.cfg = cfg
m.current = s
return nil
}
// Get 返回当前存储实例。
func (m *StorageManager) Get() Storage {
m.mu.Lock()
defer m.mu.Unlock()
return m.current
}
// Set 设置存储实例(用于注入 mock 或自定义实现)。
func (m *StorageManager) Set(s Storage) {
m.mu.Lock()
defer m.mu.Unlock()
m.current = s
}
// Close 释放存储资源,闭合 StorageManager 的 Init/Close 生命周期(与 database/redis/logger
// manager 对齐,供 App.closeResources 统一调用)。当前 LocalStorage 无可关资源;OSSStorage 的
// *oss.Client 未暴露 Closealiyun-oss-go-sdk),故当前为 no-op。保留此方法为未来驱动
// (S3/MinIO/自研等带连接池的驱动)预留收口点:实现了 io.Closer 的 Storage 实现会被自动调用,
// 框架骨架无需改动。幂等(未初始化或驱动非 io.Closer 时 no-op)。
func (m *StorageManager) Close() error {
m.mu.Lock()
current := m.current
m.mu.Unlock()
if current == nil {
return nil
}
if c, ok := current.(io.Closer); ok {
return c.Close()
}
return nil
}
// --- 包级 facade(代理到 DefaultStorage,兼容存量) ---
// Init 初始化存储
func Init(cfg *config.StorageConfig) error {
return DefaultStorage.Load().Init(cfg)
}
// GetStorage 获取全局存储实例
func GetStorage() Storage {
return DefaultStorage.Load().Get()
}
// SetStorage 设置全局存储实例
func SetStorage(s Storage) {
DefaultStorage.Load().Set(s)
}
// Upload 上传文件
func Upload(file *multipart.FileHeader, subdir string) (string, error) {
s := GetStorage()
if s == nil {
return "", ErrStorageNotInitialized
}
return s.Upload(file, subdir)
}
// UploadFromBytes 从字节数组上传文件
func UploadFromBytes(data []byte, filename, subdir string) (string, error) {
s := GetStorage()
if s == nil {
return "", ErrStorageNotInitialized
}
return s.UploadFromBytes(data, filename, subdir)
}
// GetURL 获取文件访问 URL
func GetURL(path string) string {
s := GetStorage()
if s == nil {
return ""
}
return s.GetURL(path)
}
// Delete 删除文件
func Delete(path string) error {
s := GetStorage()
if s == nil {
return ErrStorageNotInitialized
}
return s.Delete(path)
}
// Get 获取文件内容
func Get(path string) ([]byte, error) {
s := GetStorage()
if s == nil {
return nil, ErrStorageNotInitialized
}
return s.Get(path)
}
// Exists 检查文件是否存在。未初始化返回 (false, ErrStorageNotInitialized)
// 其余语义同 Storage.Exists。
func Exists(path string) (bool, error) {
s := GetStorage()
if s == nil {
return false, ErrStorageNotInitialized
}
return s.Exists(path)
}