db95b8e4ee
Add prompt_content columns to kol_prompts and kol_prompt_revisions so prompt bodies live alongside their metadata, eliminating an extra round trip to object storage for the common read path while keeping the old asset key as a fallback for legacy rows. Reads go through a singleflight-deduped, cache-backed loader that prefers the database column, falls back to the asset key, and tolerates missing objects via a new objectstorage.ErrObjectNotFound returned by both the Aliyun OSS and MinIO clients on 404/NoSuchKey responses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
5.6 KiB
Go
213 lines
5.6 KiB
Go
package objectstorage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/minio/minio-go/v7"
|
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
|
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
|
)
|
|
|
|
type minioClient struct {
|
|
client *minio.Client
|
|
bucket string
|
|
region string
|
|
logger *zap.Logger
|
|
cfg config.ObjectStorageConfig
|
|
}
|
|
|
|
func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
|
endpoint := strings.TrimSpace(cfg.Endpoint)
|
|
accessKey := strings.TrimSpace(cfg.AccessKey)
|
|
secretKey := strings.TrimSpace(cfg.SecretKey)
|
|
bucket := strings.TrimSpace(cfg.Bucket)
|
|
if endpoint == "" || accessKey == "" || secretKey == "" || bucket == "" {
|
|
return disabledClient{reason: "missing object_storage minio endpoint/access_key/secret_key/bucket"}
|
|
}
|
|
|
|
client, err := minio.New(endpoint, &minio.Options{
|
|
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
|
Secure: cfg.UseSSL,
|
|
Region: strings.TrimSpace(cfg.Region),
|
|
})
|
|
if err != nil {
|
|
return disabledClient{reason: fmt.Sprintf("init minio client failed: %v", err)}
|
|
}
|
|
|
|
return &minioClient{
|
|
client: client,
|
|
bucket: bucket,
|
|
region: strings.TrimSpace(cfg.Region),
|
|
logger: logger,
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
func (c *minioClient) Validate() error {
|
|
if c == nil || c.client == nil {
|
|
return fmt.Errorf("%w: minio client is not initialized", ErrNotConfigured)
|
|
}
|
|
if strings.TrimSpace(c.bucket) == "" {
|
|
return fmt.Errorf("%w: minio bucket is empty", ErrNotConfigured)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *minioClient) PutBytes(
|
|
ctx context.Context,
|
|
objectKey string,
|
|
content []byte,
|
|
contentType string,
|
|
) error {
|
|
if err := c.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(objectKey) == "" {
|
|
return fmt.Errorf("object key is required")
|
|
}
|
|
|
|
exists, err := c.client.BucketExists(ctx, c.bucket)
|
|
if err != nil {
|
|
c.logError(ctx, "minio bucket exists check failed",
|
|
zap.String("bucket", c.bucket),
|
|
zap.String("object_key", objectKey),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("check minio bucket: %w", err)
|
|
}
|
|
if !exists {
|
|
if err := c.client.MakeBucket(ctx, c.bucket, minio.MakeBucketOptions{Region: c.region}); err != nil {
|
|
c.logError(ctx, "minio create bucket failed",
|
|
zap.String("bucket", c.bucket),
|
|
zap.String("object_key", objectKey),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("create minio bucket: %w", err)
|
|
}
|
|
}
|
|
|
|
_, err = c.client.PutObject(ctx, c.bucket, objectKey, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{
|
|
ContentType: contentType,
|
|
})
|
|
if err != nil {
|
|
c.logError(ctx, "minio put object failed",
|
|
zap.String("bucket", c.bucket),
|
|
zap.String("object_key", objectKey),
|
|
zap.Int("content_size", len(content)),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("put minio object: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *minioClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) {
|
|
if err := c.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
reader, err := c.client.GetObject(ctx, c.bucket, objectKey, minio.GetObjectOptions{})
|
|
if err != nil {
|
|
c.logError(ctx, "minio get object failed",
|
|
zap.String("bucket", c.bucket),
|
|
zap.String("object_key", objectKey),
|
|
zap.Error(err),
|
|
)
|
|
if isMinIOObjectNotFound(err) {
|
|
return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey)
|
|
}
|
|
return nil, fmt.Errorf("get minio object: %w", err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
c.logError(ctx, "minio read object failed",
|
|
zap.String("bucket", c.bucket),
|
|
zap.String("object_key", objectKey),
|
|
zap.Error(err),
|
|
)
|
|
if isMinIOObjectNotFound(err) {
|
|
return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey)
|
|
}
|
|
return nil, fmt.Errorf("read minio object: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func isMinIOObjectNotFound(err error) bool {
|
|
responseErr := minio.ToErrorResponse(err)
|
|
return responseErr.StatusCode == 404 ||
|
|
responseErr.Code == "NoSuchKey" ||
|
|
responseErr.Code == "NoSuchObject"
|
|
}
|
|
|
|
func (c *minioClient) Exists(ctx context.Context, objectKey string) (bool, error) {
|
|
if err := c.Validate(); err != nil {
|
|
return false, err
|
|
}
|
|
if strings.TrimSpace(objectKey) == "" {
|
|
return false, nil
|
|
}
|
|
|
|
_, err := c.client.StatObject(ctx, c.bucket, objectKey, minio.StatObjectOptions{})
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
|
|
responseErr := minio.ToErrorResponse(err)
|
|
if responseErr.StatusCode == 404 || responseErr.Code == "NoSuchKey" || responseErr.Code == "NoSuchObject" {
|
|
return false, nil
|
|
}
|
|
|
|
c.logError(ctx, "minio stat object failed",
|
|
zap.String("bucket", c.bucket),
|
|
zap.String("object_key", objectKey),
|
|
zap.Error(err),
|
|
)
|
|
return false, fmt.Errorf("stat minio object: %w", err)
|
|
}
|
|
|
|
func (c *minioClient) Delete(ctx context.Context, objectKey string) error {
|
|
if err := c.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(objectKey) == "" {
|
|
return nil
|
|
}
|
|
|
|
err := c.client.RemoveObject(ctx, c.bucket, objectKey, minio.RemoveObjectOptions{})
|
|
if err != nil {
|
|
c.logError(ctx, "minio delete object failed",
|
|
zap.String("bucket", c.bucket),
|
|
zap.String("object_key", objectKey),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("delete minio object: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *minioClient) PublicURL(objectKey string) (string, error) {
|
|
if err := c.Validate(); err != nil {
|
|
return "", err
|
|
}
|
|
return buildPublicURL(c.cfg, objectKey, false)
|
|
}
|
|
|
|
func (c *minioClient) logError(ctx context.Context, msg string, fields ...zap.Field) {
|
|
if c == nil || c.logger == nil {
|
|
return
|
|
}
|
|
if requestID := middleware.RequestIDFromContext(ctx); requestID != "" {
|
|
fields = append(fields, zap.String("request_id", requestID))
|
|
}
|
|
c.logger.Error(msg, fields...)
|
|
}
|