项目文件夹

文件
2026-07-14 10:24:10 +08:00

870 行
28 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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)
}