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:
2026-05-26 01:19:01 +08:00
parent 5f6e9f11da
commit 7d8e82c69f
37 changed files with 5111 additions and 66 deletions
+178 -2
View File
@@ -5,6 +5,8 @@ import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@@ -296,6 +298,180 @@ func (c *aliyunClient) PublicURL(objectKey string) (string, error) {
return buildPublicURL(c.cfg, objectKey, true)
}
func (c *aliyunClient) PublicReadableURL(objectKey string) (string, error) {
if err := c.Validate(); err != nil {
return "", err
}
publicRead, err := c.isPublicReadable()
if err != nil {
return "", err
}
if !publicRead {
return "", ErrObjectNotPublic
}
return buildPublicURL(c.cfg, objectKey, true)
}
func (c *aliyunClient) DownloadURL(objectKey string) (string, error) {
if err := c.Validate(); err != nil {
return "", err
}
return c.signedURL(objectKey, oss.ResponseContentDisposition(downloadContentDisposition(objectKey)))
}
func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) {
if err := c.Validate(); err != nil {
return nil, err
}
stat, err := c.client.GetBucketStat(c.bucket)
if err != nil {
c.logError(ctx, "aliyun get bucket stat failed",
zap.String("bucket", c.bucket),
zap.Error(err),
)
return nil, fmt.Errorf("get aliyun bucket stat: %w", err)
}
return &UsageInfo{
Available: true,
Provider: "aliyun",
StorageSize: stat.Storage,
ObjectCount: stat.ObjectCount,
}, nil
}
func (c *aliyunClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
if err := c.Validate(); err != nil {
return nil, err
}
bucket, err := c.client.Bucket(c.bucket)
if err != nil {
return nil, fmt.Errorf("get aliyun bucket: %w", err)
}
limit := in.Limit
if limit <= 0 || limit > 1000 {
limit = 100
}
delimiter := strings.TrimSpace(in.Delimiter)
if delimiter == "" && !in.Recursive {
delimiter = "/"
}
options := []oss.Option{
oss.Prefix(normalizeObjectKeyPath(in.Prefix)),
oss.MaxKeys(limit),
}
if delimiter != "" {
options = append(options, oss.Delimiter(delimiter))
}
if token := strings.TrimSpace(in.ContinuationToken); token != "" {
options = append(options, oss.ContinuationToken(token))
}
if startAfter := normalizeObjectKeyPath(in.StartAfter); startAfter != "" {
options = append(options, oss.StartAfter(startAfter))
}
result, err := bucket.ListObjectsV2(options...)
if err != nil {
c.logError(ctx, "aliyun list objects failed",
zap.String("bucket", c.bucket),
zap.String("prefix", in.Prefix),
zap.Error(err),
)
return nil, fmt.Errorf("list aliyun objects: %w", err)
}
out := &ListResult{
Objects: make([]ObjectInfo, 0, len(result.Objects)),
CommonPrefixes: append([]string(nil), result.CommonPrefixes...),
ContinuationToken: result.ContinuationToken,
NextContinuationToken: result.NextContinuationToken,
IsTruncated: result.IsTruncated,
Prefix: result.Prefix,
Delimiter: result.Delimiter,
}
for _, item := range result.Objects {
out.Objects = append(out.Objects, ObjectInfo{
Key: item.Key,
Size: item.Size,
ETag: strings.Trim(item.ETag, `"`),
LastModified: item.LastModified,
StorageClass: item.StorageClass,
})
}
return out, nil
}
func (c *aliyunClient) Stat(ctx context.Context, objectKey string) (*ObjectInfo, error) {
if err := c.Validate(); err != nil {
return nil, err
}
objectKey = normalizeObjectKeyPath(objectKey)
if objectKey == "" {
return nil, fmt.Errorf("object key is required")
}
bucket, err := c.client.Bucket(c.bucket)
if err != nil {
return nil, fmt.Errorf("get aliyun bucket: %w", err)
}
header, err := bucket.GetObjectDetailedMeta(objectKey)
if err != nil {
c.logError(ctx, "aliyun stat object failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
if isAliyunObjectNotFound(err) {
return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey)
}
return nil, fmt.Errorf("stat aliyun object: %w", err)
}
return aliyunHeaderToObjectInfo(objectKey, header), nil
}
func (c *aliyunClient) Copy(ctx context.Context, sourceKey, destinationKey string) error {
if err := c.Validate(); err != nil {
return err
}
sourceKey = normalizeObjectKeyPath(sourceKey)
destinationKey = normalizeObjectKeyPath(destinationKey)
if sourceKey == "" || destinationKey == "" {
return fmt.Errorf("source and destination object keys are required")
}
if sourceKey == destinationKey {
return nil
}
bucket, err := c.client.Bucket(c.bucket)
if err != nil {
return fmt.Errorf("get aliyun bucket: %w", err)
}
if _, err := bucket.CopyObject(sourceKey, destinationKey); err != nil {
c.logError(ctx, "aliyun copy object failed",
zap.String("bucket", c.bucket),
zap.String("source_key", sourceKey),
zap.String("destination_key", destinationKey),
zap.Error(err),
)
if isAliyunObjectNotFound(err) {
return fmt.Errorf("%w: %s", ErrObjectNotFound, sourceKey)
}
return fmt.Errorf("copy aliyun object: %w", err)
}
return nil
}
func aliyunHeaderToObjectInfo(objectKey string, header http.Header) *ObjectInfo {
size, _ := strconv.ParseInt(strings.TrimSpace(header.Get(oss.HTTPHeaderContentLength)), 10, 64)
lastModified, _ := http.ParseTime(header.Get(oss.HTTPHeaderLastModified))
return &ObjectInfo{
Key: objectKey,
Size: size,
ETag: strings.Trim(header.Get(oss.HTTPHeaderEtag), `"`),
LastModified: lastModified,
ContentType: header.Get(oss.HTTPHeaderContentType),
StorageClass: header.Get(oss.HTTPHeaderOssStorageClass),
}
}
func (c *aliyunClient) isPublicReadable() (bool, error) {
objectACL := strings.ToLower(strings.TrimSpace(c.cfg.ObjectACL))
if isPublicReadACL(objectACL) {
@@ -317,7 +493,7 @@ func (c *aliyunClient) isPublicReadable() (bool, error) {
return isPublicReadACL(result.ACL), nil
}
func (c *aliyunClient) signedURL(objectKey string) (string, error) {
func (c *aliyunClient) signedURL(objectKey string, options ...oss.Option) (string, error) {
objectKey = normalizeObjectKeyPath(objectKey)
if objectKey == "" {
return "", fmt.Errorf("object key is required")
@@ -330,7 +506,7 @@ func (c *aliyunClient) signedURL(objectKey string) (string, error) {
if ttl <= 0 {
ttl = 30 * time.Minute
}
return bucket.SignURL(objectKey, oss.HTTPGet, int64(ttl.Seconds()))
return bucket.SignURL(objectKey, oss.HTTPGet, int64(ttl.Seconds()), options...)
}
func isPublicReadACL(acl string) bool {