feat(ops): add object storage management and site-mapping CSV import/export
Ship the Ops backstage 对象存储 page (browse / upload / move / delete / folder ops) backed by a new object-storage service + handler, with the shared storage client extended with List/Stat/Copy/Usage/DownloadURL so both MinIO and Aliyun providers cover the new surface. Add ForcePathStyle to the object-storage config and treat r2/s3/custom_s3 as external providers so Cloudflare R2 and other S3-compatibles can share the MinIO client path without spinning up the bundled MinIO. Also add CSV import/export + template download to the site-domain mappings page, raise gin MaxMultipartMemory to 8 MiB for the new multipart endpoints, register Ops swagger routes, and extend the descriptions-parity test to cover the ops router. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,708 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionObjectStorageUpload = "object_storage.upload"
|
||||
ActionObjectStorageMove = "object_storage.move"
|
||||
ActionObjectStorageDelete = "object_storage.delete"
|
||||
ActionObjectStorageFolderCreate = "object_storage.folder.create"
|
||||
ActionObjectStorageFolderDelete = "object_storage.folder.delete"
|
||||
)
|
||||
|
||||
type ObjectStorageService struct {
|
||||
storage objectstorage.ManagementClient
|
||||
audits *AuditService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewObjectStorageService(storage objectstorage.Client, audits *AuditService) *ObjectStorageService {
|
||||
management, _ := storage.(objectstorage.ManagementClient)
|
||||
return &ObjectStorageService{storage: management, audits: audits}
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) WithLogger(logger *zap.Logger) *ObjectStorageService {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
s.logger = logger
|
||||
if logger != nil && s.storage == nil {
|
||||
logger.Warn("object storage backend does not implement ManagementClient, OSS console disabled")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type ObjectStorageListInput struct {
|
||||
Prefix string
|
||||
Keyword string
|
||||
ContinuationToken string
|
||||
StartAfter string
|
||||
Recursive bool
|
||||
Size int
|
||||
}
|
||||
|
||||
type ObjectStorageListResult struct {
|
||||
Objects []objectstorage.ObjectInfo `json:"objects"`
|
||||
CommonPrefixes []string `json:"common_prefixes"`
|
||||
ContinuationToken string `json:"continuation_token,omitempty"`
|
||||
NextContinuationToken string `json:"next_continuation_token,omitempty"`
|
||||
IsTruncated bool `json:"is_truncated"`
|
||||
Prefix string `json:"prefix"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
Recursive bool `json:"recursive"`
|
||||
}
|
||||
|
||||
type ObjectStorageDetailResult struct {
|
||||
objectstorage.ObjectInfo
|
||||
PublicURL *string `json:"public_url,omitempty"`
|
||||
PreviewURL *string `json:"preview_url,omitempty"`
|
||||
}
|
||||
|
||||
type ObjectStorageUploadInput struct {
|
||||
ObjectKey string
|
||||
FileName string
|
||||
Content []byte
|
||||
ContentType string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
type ObjectStorageUploadResult struct {
|
||||
Object objectstorage.ObjectInfo `json:"object"`
|
||||
}
|
||||
|
||||
type ObjectStorageMoveInput struct {
|
||||
SourceKey string
|
||||
DestinationKey string
|
||||
Overwrite bool
|
||||
DeleteSource bool
|
||||
}
|
||||
|
||||
type ObjectStorageFolderCreateInput struct {
|
||||
Prefix string
|
||||
}
|
||||
|
||||
type ObjectStorageFolderCreateResult struct {
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
type ObjectStorageFolderDeleteResult struct {
|
||||
Prefix string `json:"prefix"`
|
||||
DeletedCount int `json:"deleted_count"`
|
||||
}
|
||||
|
||||
type ObjectStorageUsageResult struct {
|
||||
Available bool `json:"available"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
StorageSize int64 `json:"storage_size"`
|
||||
ObjectCount int64 `json:"object_count"`
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) List(ctx context.Context, in ObjectStorageListInput) (*ObjectStorageListResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := in.Size
|
||||
if size <= 0 || size > 1000 {
|
||||
size = 100
|
||||
}
|
||||
prefix, err := normalizeOSSOptionalPrefix(in.Prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyword := strings.TrimSpace(in.Keyword)
|
||||
if keyword != "" && len([]rune(keyword)) > 200 {
|
||||
return nil, response.ErrBadRequest(40093, "invalid_object_storage_keyword", "搜索关键字不能超过 200 个字")
|
||||
}
|
||||
|
||||
listInput := objectstorage.ListInput{
|
||||
Prefix: prefix,
|
||||
ContinuationToken: strings.TrimSpace(in.ContinuationToken),
|
||||
StartAfter: normalizeOSSOptionalToken(in.StartAfter),
|
||||
Recursive: in.Recursive || keyword != "",
|
||||
Limit: size,
|
||||
}
|
||||
result, err := s.storage.List(ctx, listInput)
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_list_failed", "读取 OSS 对象列表失败")
|
||||
}
|
||||
objects := normalizeOSSListedObjects(prefix, result.Objects, in.Recursive || keyword != "")
|
||||
commonPrefixes := normalizeOSSCommonPrefixes(prefix, result.CommonPrefixes, result.Objects, in.Recursive || keyword != "")
|
||||
if shouldFallbackOSSDirectoryList(in, keyword, objects, commonPrefixes) {
|
||||
listInput.Recursive = true
|
||||
result, err = s.storage.List(ctx, listInput)
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_list_failed", "读取 OSS 对象列表失败")
|
||||
}
|
||||
objects = normalizeOSSListedObjects(prefix, result.Objects, in.Recursive || keyword != "")
|
||||
commonPrefixes = normalizeOSSCommonPrefixes(prefix, result.CommonPrefixes, result.Objects, in.Recursive || keyword != "")
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
objects = filterOSSObjectsByKeyword(objects, keyword)
|
||||
}
|
||||
return &ObjectStorageListResult{
|
||||
Objects: objects,
|
||||
CommonPrefixes: commonPrefixes,
|
||||
ContinuationToken: result.ContinuationToken,
|
||||
NextContinuationToken: result.NextContinuationToken,
|
||||
IsTruncated: result.IsTruncated,
|
||||
Prefix: prefix,
|
||||
Keyword: keyword,
|
||||
Recursive: in.Recursive || keyword != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Usage(ctx context.Context) (*ObjectStorageUsageResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usageClient, ok := s.storage.(objectstorage.UsageClient)
|
||||
if !ok {
|
||||
return &ObjectStorageUsageResult{Available: false}, nil
|
||||
}
|
||||
info, err := usageClient.Usage(ctx)
|
||||
if errors.Is(err, objectstorage.ErrUsageUnsupported) {
|
||||
return &ObjectStorageUsageResult{Available: false}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_usage_failed", "读取 OSS 存储用量失败")
|
||||
}
|
||||
if info == nil || !info.Available {
|
||||
return &ObjectStorageUsageResult{Available: false}, nil
|
||||
}
|
||||
return &ObjectStorageUsageResult{
|
||||
Available: true,
|
||||
Provider: info.Provider,
|
||||
StorageSize: info.StorageSize,
|
||||
ObjectCount: info.ObjectCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Get(ctx context.Context, key string) (*ObjectStorageDetailResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := s.storage.Stat(ctx, objectKey)
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_stat_failed", "读取 OSS 对象详情失败")
|
||||
}
|
||||
publicURL := s.publicReadableURL(objectKey)
|
||||
previewURL, err := s.storage.DownloadURL(objectKey)
|
||||
if err != nil || strings.TrimSpace(previewURL) == "" {
|
||||
previewURL = ""
|
||||
}
|
||||
return &ObjectStorageDetailResult{ObjectInfo: *info, PublicURL: publicURL, PreviewURL: optionalString(previewURL)}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) publicReadableURL(objectKey string) *string {
|
||||
publicClient, ok := s.storage.(objectstorage.PublicReadableClient)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
urlValue, err := publicClient.PublicReadableURL(objectKey)
|
||||
if err != nil || strings.TrimSpace(urlValue) == "" {
|
||||
return nil
|
||||
}
|
||||
return &urlValue
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Download(ctx context.Context, key string) ([]byte, *objectstorage.ObjectInfo, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info, statErr := s.storage.Stat(ctx, objectKey)
|
||||
if statErr != nil && !errors.Is(statErr, objectstorage.ErrObjectNotFound) {
|
||||
return nil, nil, mapObjectStorageError(statErr, "object_storage_stat_failed", "读取 OSS 对象详情失败")
|
||||
}
|
||||
content, err := s.storage.GetBytes(ctx, objectKey)
|
||||
if err != nil {
|
||||
return nil, nil, mapObjectStorageError(err, "object_storage_download_failed", "下载 OSS 对象失败")
|
||||
}
|
||||
return content, info, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) ResolvePublicURL(ctx context.Context, key string) (string, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists, err := s.storage.Exists(ctx, objectKey); err != nil {
|
||||
return "", mapObjectStorageError(err, "object_storage_stat_failed", "读取 OSS 对象详情失败")
|
||||
} else if !exists {
|
||||
return "", response.ErrNotFound(40441, "object_storage_object_not_found", "OSS 对象不存在")
|
||||
}
|
||||
urlValue, err := s.storage.PublicURL(objectKey)
|
||||
if errors.Is(err, objectstorage.ErrObjectNotPublic) {
|
||||
return s.ResolveDownloadURL(ctx, objectKey)
|
||||
}
|
||||
if err != nil {
|
||||
return "", mapObjectStorageError(err, "object_storage_url_failed", "生成 OSS 访问地址失败")
|
||||
}
|
||||
return urlValue, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) ResolveDownloadURL(ctx context.Context, key string) (string, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists, err := s.storage.Exists(ctx, objectKey); err != nil {
|
||||
return "", mapObjectStorageError(err, "object_storage_stat_failed", "读取 OSS 对象详情失败")
|
||||
} else if !exists {
|
||||
return "", response.ErrNotFound(40441, "object_storage_object_not_found", "OSS 对象不存在")
|
||||
}
|
||||
urlValue, err := s.storage.DownloadURL(objectKey)
|
||||
if err != nil {
|
||||
return "", mapObjectStorageError(err, "object_storage_url_failed", "生成 OSS 下载地址失败")
|
||||
}
|
||||
return urlValue, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Upload(ctx context.Context, actor *Actor, in ObjectStorageUploadInput) (*ObjectStorageUploadResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(in.Content) == 0 {
|
||||
return nil, response.ErrBadRequest(40094, "empty_object_storage_file", "上传文件不能为空")
|
||||
}
|
||||
if len(in.Content) > 500*1024*1024 {
|
||||
return nil, response.ErrBadRequest(40095, "object_storage_file_too_large", "单个对象不能超过 500MB")
|
||||
}
|
||||
objectKey, err := normalizeUploadObjectKey(in.ObjectKey, in.FileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !in.Overwrite {
|
||||
if exists, err := s.storage.Exists(ctx, objectKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_stat_failed", "检查 OSS 对象失败")
|
||||
} else if exists {
|
||||
return nil, response.ErrConflict(40941, "object_storage_object_exists", "OSS 对象已存在")
|
||||
}
|
||||
}
|
||||
contentType := strings.TrimSpace(in.ContentType)
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(in.Content)
|
||||
}
|
||||
if err := s.storage.PutBytes(ctx, objectKey, in.Content, contentType); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_upload_failed", "上传 OSS 对象失败")
|
||||
}
|
||||
info, err := s.storage.Stat(ctx, objectKey)
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("object storage stat failed after upload; returning synthesized metadata",
|
||||
zap.String("object_key", objectKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
info = &objectstorage.ObjectInfo{
|
||||
Key: objectKey,
|
||||
Size: int64(len(in.Content)),
|
||||
ContentType: contentType,
|
||||
LastModified: time.Now(),
|
||||
}
|
||||
}
|
||||
s.audit(ctx, actor, ActionObjectStorageUpload, objectKey, map[string]any{
|
||||
"object_key": objectKey,
|
||||
"size": len(in.Content),
|
||||
"overwrite": in.Overwrite,
|
||||
})
|
||||
return &ObjectStorageUploadResult{Object: *info}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Move(ctx context.Context, actor *Actor, in ObjectStorageMoveInput) (*ObjectStorageDetailResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceKey, err := normalizeOSSObjectKey(in.SourceKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
destinationKey, err := normalizeOSSObjectKey(in.DestinationKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceKey == destinationKey {
|
||||
return s.Get(ctx, destinationKey)
|
||||
}
|
||||
if exists, err := s.storage.Exists(ctx, sourceKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_stat_failed", "检查源对象失败")
|
||||
} else if !exists {
|
||||
return nil, response.ErrNotFound(40441, "object_storage_object_not_found", "源 OSS 对象不存在")
|
||||
}
|
||||
if !in.Overwrite {
|
||||
if exists, err := s.storage.Exists(ctx, destinationKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_stat_failed", "检查目标对象失败")
|
||||
} else if exists {
|
||||
return nil, response.ErrConflict(40941, "object_storage_object_exists", "目标 OSS 对象已存在")
|
||||
}
|
||||
}
|
||||
if err := s.storage.Copy(ctx, sourceKey, destinationKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_move_failed", "移动 OSS 对象失败")
|
||||
}
|
||||
if in.DeleteSource {
|
||||
if err := s.storage.Delete(ctx, sourceKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_delete_failed", "删除源 OSS 对象失败")
|
||||
}
|
||||
}
|
||||
s.audit(ctx, actor, ActionObjectStorageMove, destinationKey, map[string]any{
|
||||
"source_key": sourceKey,
|
||||
"destination_key": destinationKey,
|
||||
"overwrite": in.Overwrite,
|
||||
"delete_source": in.DeleteSource,
|
||||
})
|
||||
return s.Get(ctx, destinationKey)
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Delete(ctx context.Context, actor *Actor, key string) error {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists, err := s.storage.Exists(ctx, objectKey); err != nil {
|
||||
return mapObjectStorageError(err, "object_storage_stat_failed", "检查 OSS 对象失败")
|
||||
} else if !exists {
|
||||
return response.ErrNotFound(40441, "object_storage_object_not_found", "OSS 对象不存在")
|
||||
}
|
||||
if err := s.storage.Delete(ctx, objectKey); err != nil {
|
||||
return mapObjectStorageError(err, "object_storage_delete_failed", "删除 OSS 对象失败")
|
||||
}
|
||||
s.audit(ctx, actor, ActionObjectStorageDelete, objectKey, map[string]any{"object_key": objectKey})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) CreateFolder(
|
||||
ctx context.Context,
|
||||
actor *Actor,
|
||||
in ObjectStorageFolderCreateInput,
|
||||
) (*ObjectStorageFolderCreateResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix, err := normalizeOSSFolderPrefix(in.Prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists, err := s.folderExists(ctx, prefix); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_folder_check_failed", "检查 OSS 目录失败")
|
||||
} else if exists {
|
||||
return nil, response.ErrConflict(40942, "object_storage_folder_exists", "OSS 目录已存在")
|
||||
}
|
||||
if err := s.storage.PutBytes(ctx, prefix, nil, "application/octet-stream"); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_folder_create_failed", "创建 OSS 目录失败")
|
||||
}
|
||||
s.audit(ctx, actor, ActionObjectStorageFolderCreate, prefix, map[string]any{"prefix": prefix})
|
||||
return &ObjectStorageFolderCreateResult{Prefix: prefix}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) DeleteFolder(
|
||||
ctx context.Context,
|
||||
actor *Actor,
|
||||
prefixValue string,
|
||||
) (*ObjectStorageFolderDeleteResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix, err := normalizeOSSFolderPrefix(prefixValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys := make([]string, 0)
|
||||
continuationToken := ""
|
||||
for {
|
||||
result, err := s.storage.List(ctx, objectstorage.ListInput{
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
Recursive: true,
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_folder_list_failed", "读取 OSS 目录失败")
|
||||
}
|
||||
for _, item := range result.Objects {
|
||||
if strings.TrimSpace(item.Key) == "" || !strings.HasPrefix(item.Key, prefix) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, item.Key)
|
||||
}
|
||||
if !result.IsTruncated || strings.TrimSpace(result.NextContinuationToken) == "" {
|
||||
break
|
||||
}
|
||||
continuationToken = strings.TrimSpace(result.NextContinuationToken)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, response.ErrNotFound(40442, "object_storage_folder_not_found", "OSS 目录不存在或目录为空")
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, key := range keys {
|
||||
if strings.TrimSpace(key) == "" || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
if err := s.storage.Delete(ctx, key); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_folder_delete_failed", "删除 OSS 目录失败")
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
s.audit(ctx, actor, ActionObjectStorageFolderDelete, prefix, map[string]any{
|
||||
"prefix": prefix,
|
||||
"deleted_count": deleted,
|
||||
})
|
||||
return &ObjectStorageFolderDeleteResult{Prefix: prefix, DeletedCount: deleted}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) ensureReady() error {
|
||||
if s == nil || s.storage == nil {
|
||||
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
if err := s.storage.Validate(); err != nil {
|
||||
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeUploadObjectKey(objectKey string, fileName string) (string, error) {
|
||||
objectKey = strings.TrimSpace(objectKey)
|
||||
if objectKey == "" || strings.HasSuffix(objectKey, "/") {
|
||||
name := sanitizeOSSFileName(fileName)
|
||||
if name == "" {
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 不能为空")
|
||||
}
|
||||
objectKey = strings.TrimRight(objectKey, "/")
|
||||
if objectKey != "" {
|
||||
objectKey += "/"
|
||||
}
|
||||
objectKey += name
|
||||
}
|
||||
return normalizeOSSObjectKey(objectKey)
|
||||
}
|
||||
|
||||
func normalizeOSSObjectKey(value string) (string, error) {
|
||||
key, err := normalizeOSSKey(value, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key == "" || strings.HasSuffix(key, "/") {
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 不能为空且不能是目录")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func normalizeOSSFolderPrefix(value string) (string, error) {
|
||||
prefix, err := normalizeOSSKey(value, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix = strings.TrimRight(prefix, "/")
|
||||
if prefix == "" {
|
||||
return "", response.ErrBadRequest(40097, "invalid_object_storage_folder", "目录路径不能为空")
|
||||
}
|
||||
return prefix + "/", nil
|
||||
}
|
||||
|
||||
func normalizeOSSOptionalPrefix(value string) (string, error) {
|
||||
return normalizeOSSKey(value, true)
|
||||
}
|
||||
|
||||
func normalizeOSSOptionalToken(value string) string {
|
||||
key, err := normalizeOSSKey(value, true)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func normalizeOSSKey(value string, allowEmpty bool) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimLeft(value, "/")
|
||||
if value == "" {
|
||||
if allowEmpty {
|
||||
return "", nil
|
||||
}
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 不能为空")
|
||||
}
|
||||
if len(value) > 1024 || strings.Contains(value, "\\") {
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 长度或格式无效")
|
||||
}
|
||||
for _, part := range strings.Split(value, "/") {
|
||||
if part == "." || part == ".." {
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 不能包含 . 或 .. 路径段")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func sanitizeOSSFileName(value string) string {
|
||||
name := strings.TrimSpace(filepath.Base(value))
|
||||
if name == "." || name == "/" || name == "\\" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(name, "\\", "")
|
||||
}
|
||||
|
||||
func filterOSSObjectsByKeyword(items []objectstorage.ObjectInfo, keyword string) []objectstorage.ObjectInfo {
|
||||
if keyword == "" {
|
||||
return items
|
||||
}
|
||||
normalized := strings.ToLower(keyword)
|
||||
filtered := make([]objectstorage.ObjectInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.Contains(strings.ToLower(item.Key), normalized) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func shouldFallbackOSSDirectoryList(
|
||||
in ObjectStorageListInput,
|
||||
keyword string,
|
||||
objects []objectstorage.ObjectInfo,
|
||||
commonPrefixes []string,
|
||||
) bool {
|
||||
if in.Recursive || keyword != "" {
|
||||
return false
|
||||
}
|
||||
return len(objects) == 0 && len(commonPrefixes) == 0
|
||||
}
|
||||
|
||||
func normalizeOSSListedObjects(prefix string, items []objectstorage.ObjectInfo, recursive bool) []objectstorage.ObjectInfo {
|
||||
if recursive {
|
||||
filtered := make([]objectstorage.ObjectInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.HasSuffix(item.Key, "/") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
filtered := make([]objectstorage.ObjectInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.HasSuffix(item.Key, "/") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(item.Key, prefix) {
|
||||
continue
|
||||
}
|
||||
remainder := strings.TrimPrefix(item.Key, prefix)
|
||||
if remainder == "" || strings.Contains(remainder, "/") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func normalizeOSSCommonPrefixes(
|
||||
prefix string,
|
||||
listedPrefixes []string,
|
||||
objects []objectstorage.ObjectInfo,
|
||||
recursive bool,
|
||||
) []string {
|
||||
if recursive {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(listedPrefixes)+len(objects))
|
||||
out := make([]string, 0, len(listedPrefixes))
|
||||
add := func(value string, requireNested bool) {
|
||||
value = strings.TrimLeft(strings.TrimSpace(value), "/")
|
||||
if value == "" || value == prefix || !strings.HasPrefix(value, prefix) {
|
||||
return
|
||||
}
|
||||
remainder := strings.TrimPrefix(value, prefix)
|
||||
first, _, hasSlash := strings.Cut(remainder, "/")
|
||||
if first == "" || (requireNested && !hasSlash) {
|
||||
return
|
||||
}
|
||||
next := prefix + first + "/"
|
||||
if _, ok := seen[next]; ok {
|
||||
return
|
||||
}
|
||||
seen[next] = struct{}{}
|
||||
out = append(out, next)
|
||||
}
|
||||
|
||||
for _, value := range listedPrefixes {
|
||||
add(value, false)
|
||||
}
|
||||
for _, item := range objects {
|
||||
add(item.Key, true)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) folderExists(ctx context.Context, prefix string) (bool, error) {
|
||||
if exists, err := s.storage.Exists(ctx, prefix); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
return true, nil
|
||||
}
|
||||
result, err := s.storage.List(ctx, objectstorage.ListInput{
|
||||
Prefix: prefix,
|
||||
Recursive: true,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(result.Objects) > 0, nil
|
||||
}
|
||||
|
||||
func mapObjectStorageError(err error, code string, detail string) error {
|
||||
if errors.Is(err, objectstorage.ErrNotConfigured) {
|
||||
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
if errors.Is(err, objectstorage.ErrObjectNotFound) {
|
||||
return response.ErrNotFound(40441, "object_storage_object_not_found", "OSS 对象不存在")
|
||||
}
|
||||
appErr := response.ErrInternal(50097, code, detail)
|
||||
appErr.Cause = err
|
||||
return appErr
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) audit(ctx context.Context, actor *Actor, action string, targetKey string, metadata map[string]any) {
|
||||
if actor == nil || s.audits == nil {
|
||||
return
|
||||
}
|
||||
event := actor.audit(action, "object_storage_object", 0, metadata)
|
||||
event.TargetID = targetKey
|
||||
_ = s.audits.Append(ctx, event)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeOSSCommonPrefixesDerivesNextLevelFolders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
objects := []objectstorage.ObjectInfo{
|
||||
{Key: "reports/2026/report.txt"},
|
||||
{Key: "reports/2026/monthly/january.txt"},
|
||||
{Key: "reports/2025/summary.txt"},
|
||||
{Key: "reports/readme.txt"},
|
||||
{Key: "images/logo.png"},
|
||||
}
|
||||
|
||||
require.Equal(t,
|
||||
[]string{"reports/2026/", "reports/2025/"},
|
||||
normalizeOSSCommonPrefixes("reports/", nil, objects, false),
|
||||
)
|
||||
require.Equal(t,
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}},
|
||||
normalizeOSSListedObjects("reports/", objects, false),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNormalizeOSSListedObjectsFiltersFolderPlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
objects := []objectstorage.ObjectInfo{
|
||||
{Key: "reports/"},
|
||||
{Key: "reports/readme.txt"},
|
||||
{Key: "reports/2026/"},
|
||||
{Key: "reports/2026/report.txt"},
|
||||
}
|
||||
|
||||
require.Equal(t,
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}},
|
||||
normalizeOSSListedObjects("reports/", objects, false),
|
||||
)
|
||||
require.Equal(t,
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}, {Key: "reports/2026/report.txt"}},
|
||||
normalizeOSSListedObjects("reports/", objects, true),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNormalizeOSSCommonPrefixesKeepsProviderPrefixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := normalizeOSSCommonPrefixes(
|
||||
"reports/",
|
||||
[]string{"reports/2026/", "reports/2025/"},
|
||||
[]objectstorage.ObjectInfo{
|
||||
{Key: "reports/2026/report.txt"},
|
||||
{Key: "reports/2025/summary.txt"},
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
require.Equal(t, []string{"reports/2026/", "reports/2025/"}, got)
|
||||
}
|
||||
|
||||
func TestNormalizeOSSCommonPrefixesOmitsFoldersInRecursiveMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := normalizeOSSCommonPrefixes(
|
||||
"reports/",
|
||||
[]string{"reports/2026/"},
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/2026/report.txt"}},
|
||||
true,
|
||||
)
|
||||
|
||||
require.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestShouldFallbackOSSDirectoryList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/"},
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
require.False(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/", Recursive: true},
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
require.False(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/"},
|
||||
"release",
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
require.False(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/"},
|
||||
"",
|
||||
nil,
|
||||
[]string{"desktop-client/releases/"},
|
||||
))
|
||||
}
|
||||
|
||||
func TestCreateFolderWritesPlaceholder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
result, err := svc.CreateFolder(context.Background(), nil, ObjectStorageFolderCreateInput{Prefix: "reports/2026"})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "reports/2026/", result.Prefix)
|
||||
require.Contains(t, storage.objects, "reports/2026/")
|
||||
require.Equal(t, []byte(nil), storage.objects["reports/2026/"])
|
||||
require.Equal(t, "application/octet-stream", storage.contentTypes["reports/2026/"])
|
||||
}
|
||||
|
||||
func TestCreateFolderRejectsExistingPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
storage.objects["reports/2026/report.txt"] = []byte("content")
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
_, err := svc.CreateFolder(context.Background(), nil, ObjectStorageFolderCreateInput{Prefix: "reports/2026/"})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "object_storage_folder_exists")
|
||||
}
|
||||
|
||||
func TestDeleteFolderDeletesAllObjectsUnderPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
storage.objects["reports/2026/"] = nil
|
||||
storage.objects["reports/2026/a.txt"] = []byte("a")
|
||||
storage.objects["reports/2026/nested/b.txt"] = []byte("b")
|
||||
storage.objects["reports/2025/a.txt"] = []byte("old")
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
result, err := svc.DeleteFolder(context.Background(), nil, "reports/2026/")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "reports/2026/", result.Prefix)
|
||||
require.Equal(t, 3, result.DeletedCount)
|
||||
require.NotContains(t, storage.objects, "reports/2026/")
|
||||
require.NotContains(t, storage.objects, "reports/2026/a.txt")
|
||||
require.NotContains(t, storage.objects, "reports/2026/nested/b.txt")
|
||||
require.Contains(t, storage.objects, "reports/2025/a.txt")
|
||||
}
|
||||
|
||||
func TestGetReturnsPreviewURLAndOmitsNonPublicURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
storage.objects["reports/secret.png"] = []byte("image")
|
||||
storage.publicReadable = false
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
result, err := svc.Get(context.Background(), "reports/secret.png")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result.PublicURL)
|
||||
require.NotNil(t, result.PreviewURL)
|
||||
require.Equal(t, "https://example.test/download/reports/secret.png", *result.PreviewURL)
|
||||
}
|
||||
|
||||
type fakeObjectStorage struct {
|
||||
objects map[string][]byte
|
||||
contentTypes map[string]string
|
||||
publicReadable bool
|
||||
}
|
||||
|
||||
func newFakeObjectStorage() *fakeObjectStorage {
|
||||
return &fakeObjectStorage{
|
||||
objects: make(map[string][]byte),
|
||||
contentTypes: make(map[string]string),
|
||||
publicReadable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) PutBytes(_ context.Context, objectKey string, content []byte, contentType string) error {
|
||||
s.objects[objectKey] = content
|
||||
s.contentTypes[objectKey] = contentType
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
|
||||
content, ok := s.objects[objectKey]
|
||||
if !ok {
|
||||
return nil, objectstorage.ErrObjectNotFound
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Exists(_ context.Context, objectKey string) (bool, error) {
|
||||
_, ok := s.objects[objectKey]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Delete(_ context.Context, objectKey string) error {
|
||||
delete(s.objects, objectKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) PublicURL(objectKey string) (string, error) {
|
||||
return "https://example.test/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) PublicReadableURL(objectKey string) (string, error) {
|
||||
if !s.publicReadable {
|
||||
return "", objectstorage.ErrObjectNotPublic
|
||||
}
|
||||
return "https://example.test/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) DownloadURL(objectKey string) (string, error) {
|
||||
return "https://example.test/download/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) List(_ context.Context, in objectstorage.ListInput) (*objectstorage.ListResult, error) {
|
||||
if in.Limit == 0 {
|
||||
return nil, errors.New("test list limit must be set")
|
||||
}
|
||||
result := &objectstorage.ListResult{}
|
||||
for key, content := range s.objects {
|
||||
if strings.HasPrefix(key, in.Prefix) {
|
||||
result.Objects = append(result.Objects, objectstorage.ObjectInfo{
|
||||
Key: key,
|
||||
Size: int64(len(content)),
|
||||
LastModified: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Stat(_ context.Context, objectKey string) (*objectstorage.ObjectInfo, error) {
|
||||
content, ok := s.objects[objectKey]
|
||||
if !ok {
|
||||
return nil, objectstorage.ErrObjectNotFound
|
||||
}
|
||||
return &objectstorage.ObjectInfo{Key: objectKey, Size: int64(len(content)), LastModified: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Copy(_ context.Context, sourceKey, destinationKey string) error {
|
||||
content, ok := s.objects[sourceKey]
|
||||
if !ok {
|
||||
return objectstorage.ErrObjectNotFound
|
||||
}
|
||||
s.objects[destinationKey] = append([]byte(nil), content...)
|
||||
return nil
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +25,12 @@ const (
|
||||
ActionSiteDomainMappingEnable = "site_domain_mapping.enable"
|
||||
ActionSiteDomainMappingDisable = "site_domain_mapping.disable"
|
||||
ActionSiteDomainMappingDelete = "site_domain_mapping.delete"
|
||||
ActionSiteDomainMappingImport = "site_domain_mapping.import"
|
||||
ActionSiteDomainMappingExport = "site_domain_mapping.export"
|
||||
|
||||
siteDomainMappingImportMaxRows = 5000
|
||||
siteDomainMappingExportMaxRows = 10000
|
||||
siteDomainMappingImportMaxErrors = 50
|
||||
)
|
||||
|
||||
type SiteDomainMappingService struct {
|
||||
@@ -63,6 +73,32 @@ type SiteDomainMappingInput struct {
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
type SiteDomainMappingImportInput struct {
|
||||
Content []byte
|
||||
}
|
||||
|
||||
type SiteDomainMappingImportError struct {
|
||||
Row int `json:"row"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type SiteDomainMappingImportResult struct {
|
||||
Total int `json:"total"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Duplicate int `json:"duplicate"`
|
||||
Errors []SiteDomainMappingImportError `json:"errors"`
|
||||
}
|
||||
|
||||
type siteDomainMappingImportColumns struct {
|
||||
Domain int
|
||||
Key int
|
||||
Name int
|
||||
Active int
|
||||
}
|
||||
|
||||
func toSiteDomainMappingView(item *domain.SiteDomainMapping) SiteDomainMappingView {
|
||||
return SiteDomainMappingView{
|
||||
ID: item.ID,
|
||||
@@ -102,6 +138,56 @@ func (s *SiteDomainMappingService) List(ctx context.Context, in SiteDomainMappin
|
||||
return &SiteDomainMappingListResult{Items: views, Total: total, Page: page, Size: size}, nil
|
||||
}
|
||||
|
||||
func (s *SiteDomainMappingService) ExportCSV(ctx context.Context, actor *Actor, in SiteDomainMappingListInput) ([]byte, error) {
|
||||
items, err := s.mappings.Export(ctx, repository.SiteDomainMappingFilter{
|
||||
Keyword: strings.TrimSpace(in.Keyword),
|
||||
IsActive: in.IsActive,
|
||||
Limit: siteDomainMappingExportMaxRows + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) > siteDomainMappingExportMaxRows {
|
||||
return nil, response.ErrBadRequest(
|
||||
40059,
|
||||
"site_domain_mapping_export_too_large",
|
||||
fmt.Sprintf("单次最多导出 %d 行,请缩小筛选范围后重试", siteDomainMappingExportMaxRows),
|
||||
)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("\xEF\xBB\xBF")
|
||||
writer := csv.NewWriter(&buf)
|
||||
if err := writer.Write([]string{"registrable_domain", "site_key", "site_name", "is_active", "created_at", "updated_at"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := writer.Write([]string{
|
||||
item.RegistrableDomain,
|
||||
item.SiteKey,
|
||||
item.SiteName,
|
||||
strconv.FormatBool(item.IsActive),
|
||||
item.CreatedAt.Format(time.RFC3339),
|
||||
item.UpdatedAt.Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if actor != nil && s.audits != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionSiteDomainMappingExport, "site_domain_mapping", 0, map[string]any{
|
||||
"count": len(items),
|
||||
"keyword": strings.TrimSpace(in.Keyword),
|
||||
"is_active": in.IsActive,
|
||||
}))
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *SiteDomainMappingService) Create(ctx context.Context, actor *Actor, in SiteDomainMappingInput) (*SiteDomainMappingView, error) {
|
||||
normalized, err := normalizeSiteDomainMappingInput(in)
|
||||
if err != nil {
|
||||
@@ -126,6 +212,85 @@ func (s *SiteDomainMappingService) Create(ctx context.Context, actor *Actor, in
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *SiteDomainMappingService) ImportCSV(ctx context.Context, actor *Actor, in SiteDomainMappingImportInput) (*SiteDomainMappingImportResult, error) {
|
||||
records, err := readSiteDomainMappingCSV(in.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return nil, response.ErrBadRequest(40053, "empty_site_domain_mapping_import", "CSV 至少需要表头和一行数据")
|
||||
}
|
||||
if len(records)-1 > siteDomainMappingImportMaxRows {
|
||||
return nil, response.ErrBadRequest(
|
||||
40054,
|
||||
"site_domain_mapping_import_too_large",
|
||||
fmt.Sprintf("单次最多导入 %d 行", siteDomainMappingImportMaxRows),
|
||||
)
|
||||
}
|
||||
|
||||
columns, err := detectSiteDomainMappingImportColumns(records[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SiteDomainMappingImportResult{
|
||||
Total: len(records) - 1,
|
||||
Errors: make([]SiteDomainMappingImportError, 0),
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for rowIndex, record := range records[1:] {
|
||||
rowNumber := rowIndex + 2
|
||||
if isBlankCSVRecord(record) {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
input, rowErr := siteDomainMappingInputFromCSVRecord(record, columns)
|
||||
if rowErr != nil {
|
||||
appendSiteDomainMappingImportError(result, rowNumber, rowErr.Error())
|
||||
continue
|
||||
}
|
||||
normalized, rowErr := normalizeSiteDomainMappingInput(input)
|
||||
if rowErr != nil {
|
||||
appendSiteDomainMappingImportError(result, rowNumber, response.Normalize(rowErr).Detail)
|
||||
continue
|
||||
}
|
||||
if firstRow, ok := seen[normalized.RegistrableDomain]; ok {
|
||||
result.Duplicate++
|
||||
result.Skipped++
|
||||
appendSiteDomainMappingImportWarning(result, rowNumber, fmt.Sprintf("域名与第 %d 行重复,已跳过", firstRow))
|
||||
continue
|
||||
}
|
||||
seen[normalized.RegistrableDomain] = rowNumber
|
||||
|
||||
upserted, rowErr := s.mappings.Upsert(ctx, repository.UpsertSiteDomainMappingInput{
|
||||
RegistrableDomain: normalized.RegistrableDomain,
|
||||
SiteKey: normalized.SiteKey,
|
||||
SiteName: normalized.SiteName,
|
||||
IsActive: normalized.IsActive,
|
||||
})
|
||||
if rowErr != nil {
|
||||
appendSiteDomainMappingImportError(result, rowNumber, rowErr.Error())
|
||||
continue
|
||||
}
|
||||
if upserted.Created {
|
||||
result.Created++
|
||||
} else {
|
||||
result.Updated++
|
||||
}
|
||||
}
|
||||
if actor != nil && s.audits != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionSiteDomainMappingImport, "site_domain_mapping", 0, map[string]any{
|
||||
"total": result.Total,
|
||||
"created": result.Created,
|
||||
"updated": result.Updated,
|
||||
"skipped": result.Skipped,
|
||||
"failed": result.Failed,
|
||||
"duplicate": result.Duplicate,
|
||||
}))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *SiteDomainMappingService) Update(ctx context.Context, actor *Actor, id int64, in SiteDomainMappingInput) (*SiteDomainMappingView, error) {
|
||||
normalized, err := normalizeSiteDomainMappingInput(in)
|
||||
if err != nil {
|
||||
@@ -240,6 +405,130 @@ func normalizeDomainName(raw string) string {
|
||||
return strings.Trim(strings.TrimSuffix(value, "."), ".")
|
||||
}
|
||||
|
||||
func readSiteDomainMappingCSV(content []byte) ([][]string, error) {
|
||||
content = bytes.TrimPrefix(content, []byte("\xEF\xBB\xBF"))
|
||||
reader := csv.NewReader(bytes.NewReader(content))
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40055, "invalid_site_domain_mapping_csv", "CSV 文件格式无效:"+err.Error())
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func detectSiteDomainMappingImportColumns(header []string) (siteDomainMappingImportColumns, error) {
|
||||
columns := siteDomainMappingImportColumns{
|
||||
Domain: -1,
|
||||
Key: -1,
|
||||
Name: -1,
|
||||
Active: -1,
|
||||
}
|
||||
for index, raw := range header {
|
||||
switch normalizeSiteDomainMappingCSVHeader(raw) {
|
||||
case "registrable_domain":
|
||||
columns.Domain = index
|
||||
case "site_key":
|
||||
columns.Key = index
|
||||
case "site_name":
|
||||
columns.Name = index
|
||||
case "is_active":
|
||||
columns.Active = index
|
||||
}
|
||||
}
|
||||
if columns.Domain < 0 || columns.Name < 0 {
|
||||
return columns, response.ErrBadRequest(
|
||||
40056,
|
||||
"invalid_site_domain_mapping_csv_header",
|
||||
"CSV 表头至少需要 registrable_domain/site_name,或中文表头:匹配域名/中文名",
|
||||
)
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func normalizeSiteDomainMappingCSVHeader(header string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(header, "\ufeff")))
|
||||
key = strings.ReplaceAll(key, " ", "_")
|
||||
key = strings.ReplaceAll(key, "-", "_")
|
||||
switch key {
|
||||
case "registrable_domain", "domain", "matched_domain", "match_domain", "host", "域名", "匹配域名", "站点域名":
|
||||
return "registrable_domain"
|
||||
case "site_key", "key", "sitekey", "站点key", "站点_key":
|
||||
return "site_key"
|
||||
case "site_name", "sitename", "name", "中文名", "站点名", "站点名称", "网站名称":
|
||||
return "site_name"
|
||||
case "is_active", "active", "enabled", "status", "状态", "启用", "是否启用":
|
||||
return "is_active"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func siteDomainMappingInputFromCSVRecord(record []string, columns siteDomainMappingImportColumns) (SiteDomainMappingInput, error) {
|
||||
active := true
|
||||
if columns.Active >= 0 {
|
||||
parsed, err := parseSiteDomainMappingActive(csvCell(record, columns.Active))
|
||||
if err != nil {
|
||||
return SiteDomainMappingInput{}, err
|
||||
}
|
||||
active = parsed
|
||||
}
|
||||
return SiteDomainMappingInput{
|
||||
RegistrableDomain: csvCell(record, columns.Domain),
|
||||
SiteKey: csvCell(record, columns.Key),
|
||||
SiteName: csvCell(record, columns.Name),
|
||||
IsActive: active,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSiteDomainMappingActive(value string) (bool, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return true, nil
|
||||
}
|
||||
switch normalized {
|
||||
case "true", "1", "yes", "y", "enabled", "enable", "active", "启用", "是":
|
||||
return true, nil
|
||||
case "false", "0", "no", "n", "disabled", "disable", "inactive", "停用", "否":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("状态只能填写启用/停用、true/false 或 1/0")
|
||||
}
|
||||
}
|
||||
|
||||
func csvCell(record []string, index int) string {
|
||||
if index < 0 || index >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(record[index])
|
||||
}
|
||||
|
||||
func isBlankCSVRecord(record []string) bool {
|
||||
for _, value := range record {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func appendSiteDomainMappingImportError(result *SiteDomainMappingImportResult, row int, message string) {
|
||||
if message == "" {
|
||||
message = "导入失败"
|
||||
}
|
||||
result.Failed++
|
||||
appendSiteDomainMappingImportWarning(result, row, message)
|
||||
}
|
||||
|
||||
func appendSiteDomainMappingImportWarning(result *SiteDomainMappingImportResult, row int, message string) {
|
||||
if message == "" {
|
||||
message = "导入失败"
|
||||
}
|
||||
if len(result.Errors) < siteDomainMappingImportMaxErrors {
|
||||
result.Errors = append(result.Errors, SiteDomainMappingImportError{Row: row, Message: message})
|
||||
}
|
||||
}
|
||||
|
||||
func isDuplicateSiteDomainMapping(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSiteDomainMappingCSVColumnsAcceptChineseHeaders(t *testing.T) {
|
||||
columns, err := detectSiteDomainMappingImportColumns([]string{"匹配域名", "站点 Key", "中文名", "状态"})
|
||||
if err != nil {
|
||||
t.Fatalf("detectSiteDomainMappingImportColumns() error = %v", err)
|
||||
}
|
||||
if columns.Domain != 0 || columns.Key != 1 || columns.Name != 2 || columns.Active != 3 {
|
||||
t.Fatalf("columns = %+v, want domain/key/name/active indexes 0/1/2/3", columns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteDomainMappingCSVColumnsRequireDomainAndName(t *testing.T) {
|
||||
if _, err := detectSiteDomainMappingImportColumns([]string{"site_key", "is_active"}); err == nil {
|
||||
t.Fatal("detectSiteDomainMappingImportColumns() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteDomainMappingInputFromCSVRecordDefaultsAndParsesActive(t *testing.T) {
|
||||
columns := siteDomainMappingImportColumns{
|
||||
Domain: 0,
|
||||
Key: 1,
|
||||
Name: 2,
|
||||
Active: 3,
|
||||
}
|
||||
input, err := siteDomainMappingInputFromCSVRecord([]string{"https://www.example.com/path", "", "示例", "停用"}, columns)
|
||||
if err != nil {
|
||||
t.Fatalf("siteDomainMappingInputFromCSVRecord() error = %v", err)
|
||||
}
|
||||
normalized, err := normalizeSiteDomainMappingInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeSiteDomainMappingInput() error = %v", err)
|
||||
}
|
||||
if normalized.RegistrableDomain != "www.example.com" {
|
||||
t.Fatalf("RegistrableDomain = %q, want www.example.com", normalized.RegistrableDomain)
|
||||
}
|
||||
if normalized.SiteKey != "www.example.com" {
|
||||
t.Fatalf("SiteKey = %q, want default domain", normalized.SiteKey)
|
||||
}
|
||||
if normalized.SiteName != "示例" {
|
||||
t.Fatalf("SiteName = %q, want 示例", normalized.SiteName)
|
||||
}
|
||||
if normalized.IsActive {
|
||||
t.Fatal("IsActive = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSiteDomainMappingActiveRejectsUnknownStatus(t *testing.T) {
|
||||
if _, err := parseSiteDomainMappingActive("待定"); err == nil {
|
||||
t.Fatal("parseSiteDomainMappingActive() error = nil, want error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user