193 lines
7.2 KiB
Go
Raw 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 app
import (
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
cos "github.com/tencentyun/cos-go-sdk-v5"
"hyapp/services/wallet-service/internal/config"
)
var errWalletOutboxArchiveObjectExists = errors.New("wallet outbox archive object already exists")
// walletOutboxArchiveCOSStore 只使用私有 bucket API不生成公网 URL也不在对象上设置 public-read ACL。
type walletOutboxArchiveCOSStore struct {
client *cos.Client
}
func newWalletOutboxArchiveCOSStore(cfg config.OutboxArchiveCOSConfig, transport http.RoundTripper) (*walletOutboxArchiveCOSStore, error) {
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", strings.TrimSpace(cfg.Bucket), strings.TrimSpace(cfg.Region)))
if err != nil {
return nil, fmt.Errorf("build wallet outbox archive COS url: %w", err)
}
if transport == nil {
transport = http.DefaultTransport
}
var authorizationTransport http.RoundTripper
if cfg.CredentialSource == "cvm_role" {
authorizationTransport = &cvmRoleAuthorizationTransport{provider: newCVMCAMCredentialProvider(cfg.CAMRoleName), transport: transport}
} else {
authorizationTransport = &cos.AuthorizationTransport{
SecretID: cfg.SecretID,
SecretKey: cfg.SecretKey,
SessionToken: cfg.SessionToken,
Transport: transport,
}
}
return &walletOutboxArchiveCOSStore{client: cos.NewClient(&cos.BaseURL{BucketURL: bucketURL}, &http.Client{
Timeout: cfg.HTTPTimeout,
Transport: authorizationTransport,
})}, nil
}
func (s *walletOutboxArchiveCOSStore) putGzipAndVerify(ctx context.Context, key string, filePath string, sizeBytes int64, gzipSHA256 string, ndjsonSHA256 string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
// 对象本体已经是 gzip 文件,不设置 Content-Encoding。Go HTTP Transport 会自动解压带
// Content-Encoding:gzip 的响应,导致后续无法同时核对“压缩对象 hash”和“解压 NDJSON hash”。
putCRC64, err := s.put(ctx, key, file, sizeBytes, "application/gzip", "", gzipSHA256)
if err != nil && !errors.Is(err, errWalletOutboxArchiveObjectExists) {
return "", err
}
crc64, err := s.verifyHead(ctx, key, sizeBytes, gzipSHA256, putCRC64)
if err != nil {
return "", err
}
if err := s.verifyGzipReadback(ctx, key, gzipSHA256, ndjsonSHA256); err != nil {
return "", err
}
return crc64, nil
}
func (s *walletOutboxArchiveCOSStore) putBytesAndVerify(ctx context.Context, key string, body []byte, contentType string, expectedSHA256 string) (string, error) {
putCRC64, err := s.put(ctx, key, strings.NewReader(string(body)), int64(len(body)), contentType, "", expectedSHA256)
if err != nil && !errors.Is(err, errWalletOutboxArchiveObjectExists) {
return "", err
}
crc64, err := s.verifyHead(ctx, key, int64(len(body)), expectedSHA256, putCRC64)
if err != nil {
return "", err
}
response, err := s.client.Object.Get(ctx, key, nil)
if err != nil {
return "", err
}
defer response.Body.Close()
hash := sha256.New()
if _, err := io.Copy(hash, response.Body); err != nil {
return "", err
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != expectedSHA256 {
return "", fmt.Errorf("wallet outbox archive COS readback hash mismatch for %s", key)
}
return crc64, nil
}
func (s *walletOutboxArchiveCOSStore) put(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string, contentEncoding string, contentSHA256 string) (string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || reader == nil || sizeBytes <= 0 {
return "", fmt.Errorf("wallet outbox archive COS put input is invalid")
}
metadata := make(http.Header)
metadata.Set("x-cos-meta-sha256", contentSHA256)
optionHeaders := make(http.Header)
// 归档键由内容指纹决定;禁止覆盖可避免 bucket versioning 在并发/重试时生成重复版本。
optionHeaders.Set("x-cos-forbid-overwrite", "true")
options := &cos.ObjectPutOptions{ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
ContentLength: sizeBytes,
ContentType: contentType,
ContentEncoding: contentEncoding,
XCosMetaXXX: &metadata,
XOptionHeader: &optionHeaders,
XCosStorageClass: "STANDARD",
XCosServerSideEncryption: "AES256",
}}
response, err := s.client.Object.Put(ctx, strings.TrimLeft(key, "/"), reader, options)
crc64 := ""
if response != nil {
crc64 = strings.TrimSpace(response.Header.Get("x-cos-hash-crc64ecma"))
}
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
if err != nil {
var cosErr *cos.ErrorResponse
if errors.As(err, &cosErr) && cosErr.Response != nil && (cosErr.Response.StatusCode == http.StatusConflict || cosErr.Response.StatusCode == http.StatusPreconditionFailed) {
return "", errWalletOutboxArchiveObjectExists
}
return "", err
}
if crc64 == "" {
return "", fmt.Errorf("wallet outbox archive COS PUT response has no CRC64 for %s", key)
}
return crc64, nil
}
func (s *walletOutboxArchiveCOSStore) verifyHead(ctx context.Context, key string, expectedSize int64, expectedSHA256 string, expectedCRC64 string) (string, error) {
response, err := s.client.Object.Head(ctx, key, nil)
if err != nil {
return "", err
}
if response.Body != nil {
defer response.Body.Close()
}
if response.ContentLength != expectedSize || response.Header.Get("x-cos-meta-sha256") != expectedSHA256 {
return "", fmt.Errorf("wallet outbox archive COS HEAD mismatch for %s", key)
}
crc64 := strings.TrimSpace(response.Header.Get("x-cos-hash-crc64ecma"))
if crc64 == "" || (expectedCRC64 != "" && crc64 != expectedCRC64) {
return "", fmt.Errorf("wallet outbox archive COS HEAD CRC64 mismatch for %s", key)
}
// SSE-COS 是归档对象的强制约束;响应缺失加密标记时不能写 verified receipt。
if !strings.EqualFold(response.Header.Get("x-cos-server-side-encryption"), "AES256") {
return "", fmt.Errorf("wallet outbox archive COS object is not SSE-COS encrypted: %s", key)
}
return crc64, nil
}
func (s *walletOutboxArchiveCOSStore) verifyGzipReadback(ctx context.Context, key string, expectedGzipSHA256 string, expectedNDJSONSHA256 string) error {
response, err := s.client.Object.Get(ctx, key, nil)
if err != nil {
return err
}
defer response.Body.Close()
gzipHash := sha256.New()
compressedReader := io.TeeReader(response.Body, gzipHash)
gzipReader, err := gzip.NewReader(compressedReader)
if err != nil {
return err
}
ndjsonHash := sha256.New()
_, copyErr := io.Copy(ndjsonHash, gzipReader)
closeErr := gzipReader.Close()
// gzip.Reader 可能在内部缓冲区之后留下响应尾部;继续读完才能验证整个压缩对象的 hash。
_, drainErr := io.Copy(io.Discard, compressedReader)
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
if drainErr != nil {
return drainErr
}
if actual := hex.EncodeToString(gzipHash.Sum(nil)); actual != expectedGzipSHA256 {
return fmt.Errorf("wallet outbox archive COS gzip hash mismatch for %s", key)
}
if actual := hex.EncodeToString(ndjsonHash.Sum(nil)); actual != expectedNDJSONSHA256 {
return fmt.Errorf("wallet outbox archive COS ndjson hash mismatch for %s", key)
}
return nil
}