package service import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "io" "net/url" "os" "strings" "time" "silk-server-go/internal/config" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" ) // S3Service Ceph S3 服务,提供 presigned URL、对象流、上传等能力 type S3Service struct { client *s3.Client endpoint string region string accessKey string secretKey string } // NewS3Service 根据配置创建 S3 服务(forcePathStyle: true,兼容 Ceph) func NewS3Service(cfg *config.Config) *S3Service { endpoint := strings.TrimSuffix(cfg.S3Endpoint, "/") client := s3.New(s3.Options{ BaseEndpoint: aws.String(endpoint), Region: cfg.S3Region, Credentials: credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, ""), UsePathStyle: true, }) return &S3Service{ client: client, endpoint: endpoint, region: cfg.S3Region, accessKey: cfg.S3AccessKey, secretKey: cfg.S3SecretKey, } } // GetPresignedURL 手动实现 V4 签名 presigned GET URL(1小时有效),与 NestJS s3.service.ts 一致 func (s *S3Service) GetPresignedURL(bucket, key string) (string, error) { expiresIn := 3600 now := time.Now().UTC() dateStamp := now.Format("20060102") amzDate := now.Format("20060102T150405Z") // 从 endpoint 提取 host(去掉 http:// 或 https:// 前缀) host := strings.TrimPrefix(strings.TrimPrefix(s.endpoint, "https://"), "http://") path := "/" + bucket + "/" + key // 构造规范化查询字符串(按字母序排列) params := url.Values{} params.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256") params.Set("X-Amz-Credential", s.accessKey+"/"+dateStamp+"/"+s.region+"/s3/aws4_request") params.Set("X-Amz-Date", amzDate) params.Set("X-Amz-Expires", fmt.Sprintf("%d", expiresIn)) params.Set("X-Amz-SignedHeaders", "host") canonQuery := params.Encode() // 规范化请求 canonHeaders := "host:" + host + "\n" canonRequest := "GET\n" + path + "\n" + canonQuery + "\n" + canonHeaders + "\nhost\nUNSIGNED-PAYLOAD" // 待签名字符串 scope := dateStamp + "/" + s.region + "/s3/aws4_request" stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + sha256Hex(canonRequest) // 签名密钥派生链:AWS4{secret} -> dateStamp -> region -> s3 -> aws4_request kDate := hmacSHA256([]byte("AWS4"+s.secretKey), dateStamp) kRegion := hmacSHA256(kDate, s.region) kService := hmacSHA256(kRegion, "s3") kSigning := hmacSHA256(kService, "aws4_request") signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign)) return s.endpoint + path + "?" + canonQuery + "&X-Amz-Signature=" + signature, nil } // GetObjectStream 获取对象流(支持 Range 请求),用于视频流代理 func (s *S3Service) GetObjectStream(bucket, key string, rangeHeader string) (*s3.GetObjectOutput, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() input := &s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), } if rangeHeader != "" { input.Range = aws.String(rangeHeader) } return s.client.GetObject(ctx, input) } // ListObjects 列举桶内对象(可按前缀过滤) func (s *S3Service) ListObjects(bucket, prefix string) ([]types.Object, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() input := &s3.ListObjectsV2Input{ Bucket: aws.String(bucket), MaxKeys: aws.Int32(100), } if prefix != "" { input.Prefix = aws.String(prefix) } result, err := s.client.ListObjectsV2(ctx, input) if err != nil { return nil, err } return result.Contents, nil } // ObjectExists 检查对象是否存在 func (s *S3Service) ObjectExists(bucket, key string) bool { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), }) return err == nil } // UploadFile 上传本地文件到 S3 func (s *S3Service) UploadFile(bucket, key, filePath string) error { file, err := os.Open(filePath) if err != nil { return err } defer file.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() _, err = s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: file, ContentType: aws.String("video/mp4"), }) return err } // EnsureBucket 确保 bucket 存在(不存在则创建),并设为公开读。 // 知识库图谱图片属公开内容(Web/小程序直接展示),生产切云 OSS 时同理由 OSS/COS 配置或 CDN 承担。 func (s *S3Service) EnsureBucket(bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}); err != nil { if _, err := s.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}); err != nil { return err } } _, err := s.client.PutBucketAcl(ctx, &s3.PutBucketAclInput{ Bucket: aws.String(bucket), ACL: types.BucketCannedACLPublicRead, }) return err } // UploadImage 上传图片对象(支持指定 Content-Type,对象设为公开读供 Web/小程序直接展示) func (s *S3Service) UploadImage(bucket, key string, body io.Reader, contentType string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: body, ContentType: aws.String(contentType), ACL: types.ObjectCannedACLPublicRead, }) return err } // Endpoint 返回 S3 endpoint(用于拼接对象公开 URL) func (s *S3Service) Endpoint() string { return s.endpoint } // sha256Hex 计算 SHA256 十六进制摘要 func sha256Hex(data string) string { h := sha256.Sum256([]byte(data)) return hex.EncodeToString(h[:]) } // hmacSHA256 计算 HMAC-SHA256 func hmacSHA256(key []byte, data string) []byte { h := hmac.New(sha256.New, key) h.Write([]byte(data)) return h.Sum(nil) }