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:
@@ -8,12 +8,15 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
)
|
||||
|
||||
const MaxMultipartMemory = 8 << 20
|
||||
|
||||
func NewEngine(server config.ServerConfig) (*gin.Engine, error) {
|
||||
config.NormalizeServerConfig(&server)
|
||||
if server.Mode == "release" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
engine := gin.New()
|
||||
engine.MaxMultipartMemory = MaxMultipartMemory
|
||||
engine.ForwardedByClientIP = true
|
||||
engine.RemoteIPHeaders = []string{"X-Forwarded-For", "X-Real-IP"}
|
||||
if err := engine.SetTrustedProxies(server.TrustedProxies); err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestNewEngineUsesForwardedClientIPFromTrustedProxy(t *testing.T) {
|
||||
TrustedProxies: []string{"10.42.0.0/16"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(MaxMultipartMemory), engine.MaxMultipartMemory)
|
||||
|
||||
engine.GET("/ip", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, c.ClientIP())
|
||||
|
||||
@@ -285,17 +285,18 @@ type QdrantConfig struct {
|
||||
}
|
||||
|
||||
type ObjectStorageConfig struct {
|
||||
Provider string `mapstructure:"provider"`
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
BucketACL string `mapstructure:"bucket_acl"`
|
||||
ObjectACL string `mapstructure:"object_acl"`
|
||||
SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"`
|
||||
Region string `mapstructure:"region"`
|
||||
Provider string `mapstructure:"provider"`
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
BucketACL string `mapstructure:"bucket_acl"`
|
||||
ObjectACL string `mapstructure:"object_acl"`
|
||||
SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"`
|
||||
Region string `mapstructure:"region"`
|
||||
ForcePathStyle bool `mapstructure:"force_path_style"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
@@ -971,6 +972,9 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok {
|
||||
cfg.ObjectStorage.Region = region
|
||||
}
|
||||
if forcePathStyle, ok := lookupBoolEnv("OBJECT_STORAGE_FORCE_PATH_STYLE"); ok {
|
||||
cfg.ObjectStorage.ForcePathStyle = forcePathStyle
|
||||
}
|
||||
if useSSL, ok := lookupBoolEnv("OBJECT_STORAGE_USE_SSL"); ok {
|
||||
cfg.ObjectStorage.UseSSL = useSSL
|
||||
}
|
||||
|
||||
@@ -112,12 +112,14 @@ func TestLoadAppliesObjectStorageEnvOverrides(t *testing.T) {
|
||||
t.Setenv("OBJECT_STORAGE_BUCKET_ACL", "private")
|
||||
t.Setenv("OBJECT_STORAGE_OBJECT_ACL", "public-read")
|
||||
t.Setenv("OBJECT_STORAGE_SIGNED_URL_TTL", "45m")
|
||||
t.Setenv("OBJECT_STORAGE_FORCE_PATH_STYLE", "true")
|
||||
|
||||
configPath := writeTestConfig(t, `
|
||||
object_storage:
|
||||
bucket_acl: public-read
|
||||
object_acl: private
|
||||
signed_url_ttl: 10m
|
||||
force_path_style: false
|
||||
`)
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
@@ -134,6 +136,9 @@ object_storage:
|
||||
if cfg.ObjectStorage.SignedURLTTL != 45*time.Minute {
|
||||
t.Fatalf("expected env signed url ttl, got %s", cfg.ObjectStorage.SignedURLTTL)
|
||||
}
|
||||
if !cfg.ObjectStorage.ForcePathStyle {
|
||||
t.Fatal("expected env force path style true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesBrandLibraryDefaults(t *testing.T) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
|
||||
var ErrNotConfigured = errors.New("object storage is not configured")
|
||||
var ErrObjectNotFound = errors.New("object storage object not found")
|
||||
var ErrObjectNotPublic = errors.New("object storage object is not publicly readable")
|
||||
var ErrUsageUnsupported = errors.New("object storage usage stat is not supported")
|
||||
|
||||
type Client interface {
|
||||
Validate() error
|
||||
@@ -21,6 +24,57 @@ type Client interface {
|
||||
Exists(ctx context.Context, objectKey string) (bool, error)
|
||||
Delete(ctx context.Context, objectKey string) error
|
||||
PublicURL(objectKey string) (string, error)
|
||||
DownloadURL(objectKey string) (string, error)
|
||||
}
|
||||
|
||||
type ManagementClient interface {
|
||||
Client
|
||||
List(ctx context.Context, in ListInput) (*ListResult, error)
|
||||
Stat(ctx context.Context, objectKey string) (*ObjectInfo, error)
|
||||
Copy(ctx context.Context, sourceKey, destinationKey string) error
|
||||
}
|
||||
|
||||
type UsageClient interface {
|
||||
Usage(ctx context.Context) (*UsageInfo, error)
|
||||
}
|
||||
|
||||
type PublicReadableClient interface {
|
||||
PublicReadableURL(objectKey string) (string, error)
|
||||
}
|
||||
|
||||
type ListInput struct {
|
||||
Prefix string
|
||||
ContinuationToken string
|
||||
StartAfter string
|
||||
Delimiter string
|
||||
Limit int
|
||||
Recursive bool
|
||||
}
|
||||
|
||||
type ListResult struct {
|
||||
Objects []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"`
|
||||
Delimiter string `json:"delimiter"`
|
||||
}
|
||||
|
||||
type ObjectInfo struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified time.Time `json:"last_modified"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
StorageClass string `json:"storage_class,omitempty"`
|
||||
}
|
||||
|
||||
type UsageInfo struct {
|
||||
Available bool `json:"available"`
|
||||
Provider string `json:"provider"`
|
||||
StorageSize int64 `json:"storage_size"`
|
||||
ObjectCount int64 `json:"object_count"`
|
||||
}
|
||||
|
||||
func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
||||
@@ -28,7 +82,7 @@ func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
||||
switch provider {
|
||||
case "", "disabled":
|
||||
return disabledClient{reason: "object storage is disabled"}
|
||||
case "minio", "mino":
|
||||
case "minio", "mino", "r2", "cloudflare_r2", "cloudflare-r2", "s3", "aws_s3", "aws-s3", "custom", "custom_s3", "custom-s3":
|
||||
return NewMinIOClient(cfg, logger)
|
||||
case "aliyun", "aliyun_oss", "aliyun-oss", "oss":
|
||||
return NewAliyunClient(cfg, logger)
|
||||
@@ -67,3 +121,23 @@ func (c disabledClient) Delete(context.Context, string) error {
|
||||
func (c disabledClient) PublicURL(string) (string, error) {
|
||||
return "", c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) DownloadURL(string) (string, error) {
|
||||
return "", c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) List(context.Context, ListInput) (*ListResult, error) {
|
||||
return nil, c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) Stat(context.Context, string) (*ObjectInfo, error) {
|
||||
return nil, c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) Copy(context.Context, string, string) error {
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) Usage(context.Context) (*UsageInfo, error) {
|
||||
return nil, c.Validate()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
@@ -32,10 +34,17 @@ func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
||||
return disabledClient{reason: "missing object_storage minio endpoint/access_key/secret_key/bucket"}
|
||||
}
|
||||
|
||||
region := normalizeMinIORegion(cfg)
|
||||
bucketLookup := minio.BucketLookupAuto
|
||||
if cfg.ForcePathStyle || isPathStyleProvider(cfg.Provider) {
|
||||
bucketLookup = minio.BucketLookupPath
|
||||
}
|
||||
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: strings.TrimSpace(cfg.Region),
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: region,
|
||||
BucketLookup: bucketLookup,
|
||||
})
|
||||
if err != nil {
|
||||
return disabledClient{reason: fmt.Sprintf("init minio client failed: %v", err)}
|
||||
@@ -44,12 +53,41 @@ func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
||||
return &minioClient{
|
||||
client: client,
|
||||
bucket: bucket,
|
||||
region: strings.TrimSpace(cfg.Region),
|
||||
region: region,
|
||||
logger: logger,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMinIORegion(cfg config.ObjectStorageConfig) string {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region != "" {
|
||||
return region
|
||||
}
|
||||
if isR2Provider(cfg.Provider) {
|
||||
return "auto"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isR2Provider(provider string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "r2", "cloudflare_r2", "cloudflare-r2":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPathStyleProvider(provider string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "r2", "cloudflare_r2", "cloudflare-r2":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *minioClient) Validate() error {
|
||||
if c == nil || c.client == nil {
|
||||
return fmt.Errorf("%w: minio client is not initialized", ErrNotConfigured)
|
||||
@@ -198,9 +236,167 @@ func (c *minioClient) PublicURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !c.isPublicReadable() {
|
||||
return c.DownloadURL(objectKey)
|
||||
}
|
||||
return buildPublicURL(c.cfg, objectKey, false)
|
||||
}
|
||||
|
||||
func (c *minioClient) PublicReadableURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !c.isPublicReadable() {
|
||||
return "", ErrObjectNotPublic
|
||||
}
|
||||
return buildPublicURL(c.cfg, objectKey, false)
|
||||
}
|
||||
|
||||
func (c *minioClient) isPublicReadable() bool {
|
||||
return isPublicReadACL(c.cfg.ObjectACL) || isPublicReadACL(c.cfg.BucketACL)
|
||||
}
|
||||
|
||||
func (c *minioClient) DownloadURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return "", fmt.Errorf("object key is required")
|
||||
}
|
||||
ttl := c.cfg.SignedURLTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("response-content-disposition", downloadContentDisposition(objectKey))
|
||||
u, err := c.client.PresignedGetObject(context.Background(), c.bucket, objectKey, ttl, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign minio download url: %w", err)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *minioClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
delimiter := strings.TrimSpace(in.Delimiter)
|
||||
if delimiter == "" && !in.Recursive {
|
||||
delimiter = "/"
|
||||
}
|
||||
|
||||
core := minio.Core{Client: c.client}
|
||||
result, err := core.ListObjectsV2(
|
||||
c.bucket,
|
||||
normalizeObjectKeyPath(in.Prefix),
|
||||
normalizeObjectKeyPath(in.StartAfter),
|
||||
strings.TrimSpace(in.ContinuationToken),
|
||||
delimiter,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
c.logError(ctx, "minio list objects failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("prefix", in.Prefix),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("list minio objects: %w", err)
|
||||
}
|
||||
|
||||
out := &ListResult{
|
||||
Objects: make([]ObjectInfo, 0, len(result.Contents)),
|
||||
CommonPrefixes: make([]string, 0, len(result.CommonPrefixes)),
|
||||
ContinuationToken: result.ContinuationToken,
|
||||
NextContinuationToken: result.NextContinuationToken,
|
||||
IsTruncated: result.IsTruncated,
|
||||
Prefix: result.Prefix,
|
||||
Delimiter: result.Delimiter,
|
||||
}
|
||||
for _, item := range result.Contents {
|
||||
out.Objects = append(out.Objects, ObjectInfo{
|
||||
Key: item.Key,
|
||||
Size: item.Size,
|
||||
ETag: strings.Trim(item.ETag, `"`),
|
||||
LastModified: item.LastModified,
|
||||
ContentType: item.ContentType,
|
||||
StorageClass: item.StorageClass,
|
||||
})
|
||||
}
|
||||
for _, prefix := range result.CommonPrefixes {
|
||||
if prefix.Prefix != "" {
|
||||
out.CommonPrefixes = append(out.CommonPrefixes, prefix.Prefix)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *minioClient) 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")
|
||||
}
|
||||
item, err := c.client.StatObject(ctx, c.bucket, objectKey, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
c.logError(ctx, "minio stat 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("stat minio object: %w", err)
|
||||
}
|
||||
return &ObjectInfo{
|
||||
Key: item.Key,
|
||||
Size: item.Size,
|
||||
ETag: strings.Trim(item.ETag, `"`),
|
||||
LastModified: item.LastModified,
|
||||
ContentType: item.ContentType,
|
||||
StorageClass: item.StorageClass,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *minioClient) 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
|
||||
}
|
||||
_, err := c.client.CopyObject(ctx,
|
||||
minio.CopyDestOptions{Bucket: c.bucket, Object: destinationKey},
|
||||
minio.CopySrcOptions{Bucket: c.bucket, Object: sourceKey},
|
||||
)
|
||||
if err != nil {
|
||||
c.logError(ctx, "minio copy object failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("source_key", sourceKey),
|
||||
zap.String("destination_key", destinationKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
if isMinIOObjectNotFound(err) {
|
||||
return fmt.Errorf("%w: %s", ErrObjectNotFound, sourceKey)
|
||||
}
|
||||
return fmt.Errorf("copy minio object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *minioClient) logError(ctx context.Context, msg string, fields ...zap.Field) {
|
||||
if c == nil || c.logger == nil {
|
||||
return
|
||||
|
||||
@@ -47,6 +47,55 @@ func (c *ReloadableClient) PublicURL(objectKey string) (string, error) {
|
||||
return c.snapshot().PublicURL(objectKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) DownloadURL(objectKey string) (string, error) {
|
||||
return c.snapshot().DownloadURL(objectKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
|
||||
client := c.snapshot()
|
||||
management, ok := client.(ManagementClient)
|
||||
if !ok {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return management.List(ctx, in)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) Stat(ctx context.Context, objectKey string) (*ObjectInfo, error) {
|
||||
client := c.snapshot()
|
||||
management, ok := client.(ManagementClient)
|
||||
if !ok {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return management.Stat(ctx, objectKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) Copy(ctx context.Context, sourceKey, destinationKey string) error {
|
||||
client := c.snapshot()
|
||||
management, ok := client.(ManagementClient)
|
||||
if !ok {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
return management.Copy(ctx, sourceKey, destinationKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) Usage(ctx context.Context) (*UsageInfo, error) {
|
||||
client := c.snapshot()
|
||||
usageClient, ok := client.(UsageClient)
|
||||
if !ok {
|
||||
return nil, ErrUsageUnsupported
|
||||
}
|
||||
return usageClient.Usage(ctx)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) PublicReadableURL(objectKey string) (string, error) {
|
||||
client := c.snapshot()
|
||||
publicClient, ok := client.(PublicReadableClient)
|
||||
if !ok {
|
||||
return "", ErrObjectNotPublic
|
||||
}
|
||||
return publicClient.PublicReadableURL(objectKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) snapshot() Client {
|
||||
if c == nil {
|
||||
return disabledClient{reason: "object storage client is nil"}
|
||||
|
||||
@@ -3,6 +3,7 @@ package objectstorage
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
@@ -65,6 +66,66 @@ func buildPublicURL(cfg config.ObjectStorageConfig, objectKey string, bucketInHo
|
||||
return endpointURL.String(), nil
|
||||
}
|
||||
|
||||
func DownloadContentDisposition(objectKey string) string {
|
||||
name := strings.TrimSpace(filepath.Base(strings.TrimRight(objectKey, "/")))
|
||||
if name == "" || name == "." || name == "/" || name == "\\" {
|
||||
name = "object"
|
||||
}
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r == '"' || r == '\\' || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
if name == "" {
|
||||
name = "object"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`attachment; filename="%s"; filename*=UTF-8''%s`,
|
||||
asciiContentDispositionFileName(name),
|
||||
url.PathEscape(name),
|
||||
)
|
||||
}
|
||||
|
||||
func downloadContentDisposition(objectKey string) string {
|
||||
return DownloadContentDisposition(objectKey)
|
||||
}
|
||||
|
||||
func asciiContentDispositionFileName(name string) string {
|
||||
var builder strings.Builder
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r >= 0x7f || r == '"' || r == '\\' {
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
|
||||
fallback := strings.TrimSpace(builder.String())
|
||||
if fallback == "" || fallback == "." || fallback == ".." || strings.HasPrefix(fallback, ".") {
|
||||
return "object" + safeASCIIExtension(name)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func safeASCIIExtension(name string) string {
|
||||
ext := filepath.Ext(name)
|
||||
if ext == "" || ext == "." {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, r := range ext {
|
||||
if r < 0x20 || r >= 0x7f || r == '"' || r == '\\' {
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
ext = strings.TrimSpace(builder.String())
|
||||
if !strings.HasPrefix(ext, ".") || ext == "." {
|
||||
return ""
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
func parseURLWithScheme(raw string, defaultScheme string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -51,3 +51,23 @@ func TestNormalizeObjectKeyPathDecodesEncodedSegments(t *testing.T) {
|
||||
t.Fatalf("object key = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadContentDispositionUsesRFC5987FileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := DownloadContentDisposition("reports/省心推 安装包.zip")
|
||||
want := `attachment; filename="object.zip"; filename*=UTF-8''%E7%9C%81%E5%BF%83%E6%8E%A8%20%E5%AE%89%E8%A3%85%E5%8C%85.zip`
|
||||
if got != want {
|
||||
t.Fatalf("content disposition = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadContentDispositionKeepsASCIIFileNameFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := DownloadContentDisposition(`reports/report "final".csv`)
|
||||
want := `attachment; filename="report final.csv"; filename*=UTF-8''report%20final.csv`
|
||||
if got != want {
|
||||
t.Fatalf("content disposition = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,110 @@ var routeDocs = map[string]routeDoc{
|
||||
"GET /api/health/live": {"存活探针", "Kubernetes liveness 探针,永远返回 200。"},
|
||||
"GET /api/health/ready": {"就绪探针", "Kubernetes readiness 探针,依赖(数据库等)就绪后返回 200。"},
|
||||
|
||||
// --- Ops:认证与账号 ---
|
||||
"GET /api/ops/auth/password-key": {"Ops 登录密码加密公钥", "返回 Ops 登录密码前端加密使用的临时公钥与 key_id。"},
|
||||
"POST /api/ops/auth/login": {"Ops 账号密码登录", "Ops 控制台账号密码登录,返回 access token。"},
|
||||
"GET /api/ops/auth/me": {"Ops 当前用户", "返回当前 Ops JWT 对应的账号信息。"},
|
||||
"POST /api/ops/auth/password": {"Ops 修改密码", "当前 Ops 用户修改自己的密码。"},
|
||||
|
||||
"GET /api/ops/accounts": {"Ops 账号列表", "分页查询 Ops 管理员账号。"},
|
||||
"POST /api/ops/accounts": {"新建 Ops 账号", "创建新的 Ops 管理员账号。"},
|
||||
"GET /api/ops/accounts/:id": {"Ops 账号详情", "读取指定 Ops 管理员账号详情。"},
|
||||
"PATCH /api/ops/accounts/:id": {"更新 Ops 账号", "更新指定 Ops 管理员账号资料。"},
|
||||
"POST /api/ops/accounts/:id/status": {"切换 Ops 账号状态", "启用或停用指定 Ops 管理员账号。"},
|
||||
"POST /api/ops/accounts/:id/password": {"重置 Ops 账号密码", "为指定 Ops 管理员账号重置登录密码。"},
|
||||
|
||||
"GET /api/ops/plans": {"租户套餐列表", "返回可分配给租户管理员的套餐列表。"},
|
||||
"GET /api/ops/roles": {"Ops 角色列表", "返回 Ops 控制台可用角色列表。"},
|
||||
|
||||
"GET /api/ops/admin-users": {"租户管理员列表", "分页查询租户管理员账号。"},
|
||||
"POST /api/ops/admin-users": {"新建租户管理员", "创建租户管理员账号。"},
|
||||
"GET /api/ops/admin-users/:id": {"租户管理员详情", "读取租户管理员账号详情。"},
|
||||
"PATCH /api/ops/admin-users/:id": {"更新租户管理员", "更新租户管理员基础资料。"},
|
||||
"POST /api/ops/admin-users/:id/plan": {"调整租户套餐", "调整租户管理员账号绑定的套餐。"},
|
||||
"POST /api/ops/admin-users/:id/subscription-expiry": {"调整订阅到期时间", "更新租户管理员账号订阅到期时间。"},
|
||||
"POST /api/ops/admin-users/:id/reset-plan-usage": {"重置套餐用量", "重置租户管理员账号套餐用量。"},
|
||||
"POST /api/ops/admin-users/:id/role": {"调整租户角色", "调整租户管理员账号角色。"},
|
||||
"POST /api/ops/admin-users/:id/kol": {"设置 KOL 身份", "设置租户管理员账号的 KOL 状态。"},
|
||||
"POST /api/ops/admin-users/:id/status": {"切换租户管理员状态", "启用或停用租户管理员账号。"},
|
||||
"POST /api/ops/admin-users/:id/password": {"重置租户管理员密码", "重置租户管理员账号登录密码。"},
|
||||
"POST /api/ops/admin-users/:id/reset-login-lock": {"解除登录锁定", "解除租户管理员账号的登录失败锁定。"},
|
||||
|
||||
// --- Ops:业务运营 ---
|
||||
"GET /api/ops/kol/packages": {"KOL 套餐列表(Ops)", "Ops 查看 KOL 市场套餐。"},
|
||||
"GET /api/ops/kol/subscriptions": {"KOL 订阅列表(Ops)", "Ops 查看租户 KOL 订阅记录。"},
|
||||
"POST /api/ops/kol/subscriptions/manual-bind": {"手动绑定 KOL 订阅", "Ops 为租户手动绑定 KOL 订阅。"},
|
||||
"POST /api/ops/kol/subscriptions/:id/approve": {"审批 KOL 订阅", "Ops 审批指定 KOL 订阅。"},
|
||||
"POST /api/ops/kol/subscriptions/:id/revoke": {"撤销 KOL 订阅", "Ops 撤销指定 KOL 订阅。"},
|
||||
"GET /api/ops/audits": {"审计日志列表", "分页查询 Ops 审计日志。"},
|
||||
"GET /api/ops/jobs": {"任务列表(Ops)", "分页查询后台任务。"},
|
||||
"GET /api/ops/jobs/:source/:id": {"任务详情(Ops)", "读取指定来源与 ID 的任务详情。"},
|
||||
"POST /api/ops/jobs/:source/:id/retry": {"重试任务(Ops)", "对失败任务发起重试。"},
|
||||
"POST /api/ops/jobs/:source/:id/cancel": {"取消任务(Ops)", "取消指定后台任务。"},
|
||||
|
||||
// --- Ops:调度器 ---
|
||||
"GET /api/ops/scheduler/jobs": {"调度任务列表", "分页查询调度器任务。"},
|
||||
"GET /api/ops/scheduler/jobs/:key": {"调度任务详情", "读取指定调度任务详情。"},
|
||||
"PATCH /api/ops/scheduler/jobs/:key": {"更新调度任务", "更新调度任务配置。"},
|
||||
"POST /api/ops/scheduler/jobs/:key/enable": {"启用调度任务", "启用指定调度任务。"},
|
||||
"POST /api/ops/scheduler/jobs/:key/disable": {"禁用调度任务", "禁用指定调度任务。"},
|
||||
"POST /api/ops/scheduler/jobs/:key/run": {"手动触发调度任务", "立即触发指定调度任务。"},
|
||||
"POST /api/ops/scheduler/jobs/:key/dry-run": {"试运行调度任务", "以 dry-run 模式触发指定调度任务。"},
|
||||
"GET /api/ops/scheduler/jobs/:key/runs": {"调度运行历史", "查询指定调度任务运行历史。"},
|
||||
"GET /api/ops/scheduler/instances": {"调度器实例列表", "查询当前调度器实例状态。"},
|
||||
|
||||
// --- Ops:站点映射 ---
|
||||
"GET /api/ops/site-domain-mappings": {"站点域名映射列表", "分页查询站点域名映射。"},
|
||||
"GET /api/ops/site-domain-mappings/export": {"导出站点域名映射", "按筛选条件导出站点域名映射 CSV。"},
|
||||
"POST /api/ops/site-domain-mappings/import": {"导入站点域名映射", "Multipart 上传 CSV 批量导入站点域名映射。"},
|
||||
"POST /api/ops/site-domain-mappings": {"新建站点域名映射", "创建站点域名映射。"},
|
||||
"PATCH /api/ops/site-domain-mappings/:id": {"更新站点域名映射", "更新指定站点域名映射。"},
|
||||
"POST /api/ops/site-domain-mappings/:id/active": {"切换站点域名映射状态", "启用或停用指定站点域名映射。"},
|
||||
"DELETE /api/ops/site-domain-mappings/:id": {"删除站点域名映射", "删除指定站点域名映射。"},
|
||||
|
||||
// --- Ops:对象存储 ---
|
||||
"GET /api/ops/object-storage/objects": {"对象存储列表", "按前缀、关键字和递归模式查询对象存储文件。"},
|
||||
"GET /api/ops/object-storage/usage": {"对象存储用量", "读取对象存储 Bucket 原生用量统计。"},
|
||||
"GET /api/ops/object-storage/objects/detail": {"对象详情", "读取对象元数据、公开访问地址和预览地址。"},
|
||||
"GET /api/ops/object-storage/objects/url": {"解析对象访问地址", "生成对象可访问 URL;私有 Bucket 返回短期签名地址。"},
|
||||
"GET /api/ops/object-storage/objects/download-url": {"解析对象下载地址", "生成带下载文件名响应头的短期签名下载地址。"},
|
||||
"GET /api/ops/object-storage/objects/download": {"下载对象", "通过 Ops API 代理下载对象。"},
|
||||
"POST /api/ops/object-storage/objects/upload": {"上传对象", "Multipart 上传单个对象,服务端限制 500MB。"},
|
||||
"POST /api/ops/object-storage/objects/move": {"移动或重命名对象", "复制对象到目标 Key,可选择删除源对象。"},
|
||||
"DELETE /api/ops/object-storage/objects": {"删除对象", "删除指定 Object Key 的对象。"},
|
||||
"POST /api/ops/object-storage/folders": {"创建对象存储目录", "创建以 / 结尾的目录占位对象。"},
|
||||
"DELETE /api/ops/object-storage/folders": {"删除对象存储目录", "按前缀递归删除目录下对象。"},
|
||||
|
||||
// --- Ops:桌面客户端版本 ---
|
||||
"GET /api/ops/desktop-client/releases": {"桌面客户端版本列表", "分页查询桌面客户端版本配置。"},
|
||||
"POST /api/ops/desktop-client/releases/upload": {"上传桌面客户端包", "Multipart 上传桌面客户端安装包或更新包。"},
|
||||
"POST /api/ops/desktop-client/releases": {"新建桌面客户端版本", "创建桌面客户端版本配置。"},
|
||||
"PATCH /api/ops/desktop-client/releases/:id": {"更新桌面客户端版本", "更新桌面客户端版本配置。"},
|
||||
"GET /api/ops/desktop-client/releases/:id/download-url": {"解析桌面客户端下载地址", "解析指定桌面客户端版本的下载地址。"},
|
||||
"POST /api/ops/desktop-client/releases/:id/enabled": {"切换桌面客户端版本状态", "启用或禁用指定桌面客户端版本。"},
|
||||
"DELETE /api/ops/desktop-client/releases/:id": {"删除桌面客户端版本", "删除指定桌面客户端版本配置。"},
|
||||
|
||||
// --- Ops:合规 ---
|
||||
"GET /api/ops/compliance/dictionaries": {"合规词库列表", "分页查询内容合规词库。"},
|
||||
"POST /api/ops/compliance/dictionaries": {"新建合规词库", "创建内容合规词库。"},
|
||||
"PUT /api/ops/compliance/dictionaries/:id": {"更新合规词库", "更新内容合规词库。"},
|
||||
"DELETE /api/ops/compliance/dictionaries/:id": {"删除合规词库", "删除内容合规词库。"},
|
||||
"GET /api/ops/compliance/dictionaries/:id/terms": {"合规词条列表", "查询指定词库下的合规词条。"},
|
||||
"POST /api/ops/compliance/dictionaries/:id/terms": {"新增合规词条", "向指定词库新增合规词条。"},
|
||||
"POST /api/ops/compliance/dictionaries/:id/terms:batch-import": {"批量导入合规词条", "向指定词库批量导入合规词条。"},
|
||||
"DELETE /api/ops/compliance/dictionaries/:id/terms/:term_id": {"删除合规词条", "删除指定合规词条。"},
|
||||
"POST /api/ops/compliance/dictionaries/:id/publish": {"发布合规词库", "发布指定合规词库版本。"},
|
||||
"POST /api/ops/compliance/dictionaries/:id/rollback": {"回滚合规词库", "回滚指定合规词库版本。"},
|
||||
"GET /api/ops/compliance/policy/global": {"全局合规策略", "读取全局内容合规策略。"},
|
||||
"PUT /api/ops/compliance/policy/global": {"更新全局合规策略", "更新全局内容合规策略。"},
|
||||
"POST /api/ops/compliance/policy/global/master-switch": {"切换合规总开关", "启用或关闭全局内容合规总开关。"},
|
||||
"GET /api/ops/compliance/manual-reviews": {"人工审阅列表", "分页查询内容合规人工审阅。"},
|
||||
"GET /api/ops/compliance/manual-reviews/:id": {"人工审阅详情", "读取指定人工审阅详情。"},
|
||||
"POST /api/ops/compliance/manual-reviews/:id/approve": {"通过人工审阅", "通过指定内容合规人工审阅。"},
|
||||
"POST /api/ops/compliance/manual-reviews/:id/reject": {"驳回人工审阅", "驳回指定内容合规人工审阅。"},
|
||||
"GET /api/ops/compliance/stats": {"合规统计", "读取内容合规统计数据。"},
|
||||
"GET /api/ops/compliance/records": {"合规检测记录", "分页查询内容合规检测记录。"},
|
||||
|
||||
// --- Auth ---
|
||||
"GET /api/auth/password-key": {"登录密码加密公钥", "返回登录密码前端加密使用的临时公钥与 key_id。"},
|
||||
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
|
||||
|
||||
@@ -13,14 +13,17 @@ import (
|
||||
// TestRouteDocs_CoversEveryRouterEntry parses the tenant-api router source and
|
||||
// asserts that every registered route has a corresponding entry in routeDocs.
|
||||
// This is a static-analysis safety net: when a new handler is wired up in
|
||||
// internal/tenant/transport/router.go, this test fails until a Chinese
|
||||
// tenant or ops transport routers, this test fails until a Chinese
|
||||
// summary is added.
|
||||
func TestRouteDocs_CoversEveryRouterEntry(t *testing.T) {
|
||||
routerPath, bootstrapPath := findRepoSources(t)
|
||||
routerPaths, bootstrapPath := findRepoSources(t)
|
||||
|
||||
routes := append(parseRoutes(t, routerPath), parseEngineRoutes(t, bootstrapPath)...)
|
||||
routes := parseEngineRoutes(t, bootstrapPath)
|
||||
for _, routerPath := range routerPaths {
|
||||
routes = append(routes, parseRoutes(t, routerPath)...)
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
t.Fatalf("could not extract any routes from %s", routerPath)
|
||||
t.Fatalf("could not extract any routes from %s", strings.Join(routerPaths, ", "))
|
||||
}
|
||||
|
||||
missing := make([]string, 0)
|
||||
@@ -133,7 +136,7 @@ func parseEngineRoutes(t *testing.T, path string) []parsedRoute {
|
||||
return routes
|
||||
}
|
||||
|
||||
func findRepoSources(t *testing.T) (router, bootstrap string) {
|
||||
func findRepoSources(t *testing.T) (routers []string, bootstrap string) {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
@@ -142,6 +145,9 @@ func findRepoSources(t *testing.T) (router, bootstrap string) {
|
||||
// .../server/internal/shared/swagger/descriptions_parity_test.go ->
|
||||
// .../server is three levels up from this file's parent dir.
|
||||
serverRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
|
||||
return filepath.Join(serverRoot, "internal", "tenant", "transport", "router.go"),
|
||||
return []string{
|
||||
filepath.Join(serverRoot, "internal", "tenant", "transport", "router.go"),
|
||||
filepath.Join(serverRoot, "internal", "ops", "transport", "router.go"),
|
||||
},
|
||||
filepath.Join(serverRoot, "internal", "bootstrap", "bootstrap.go")
|
||||
}
|
||||
|
||||
@@ -303,6 +303,17 @@ func queryParameterNames(route gin.RouteInfo) []string {
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(path, "/ops/site-domain-mappings"):
|
||||
add("page", "size", "keyword", "is_active")
|
||||
case strings.Contains(path, "/ops/object-storage/objects/detail") ||
|
||||
strings.Contains(path, "/ops/object-storage/objects/url") ||
|
||||
strings.Contains(path, "/ops/object-storage/objects/download-url") ||
|
||||
strings.Contains(path, "/ops/object-storage/objects/download"):
|
||||
add("key")
|
||||
case strings.Contains(path, "/ops/object-storage/objects"):
|
||||
add("prefix", "keyword", "continuation_token", "start_after", "recursive", "size", "key")
|
||||
case strings.Contains(path, "/ops/object-storage/folders"):
|
||||
add("prefix")
|
||||
case strings.Contains(path, "/workspace/ai-point-usage"):
|
||||
add("page", "page_size")
|
||||
case strings.Contains(path, "/tenant/articles"):
|
||||
@@ -357,13 +368,13 @@ func queryParameter(name string) map[string]any {
|
||||
|
||||
func schemaForQueryParameter(name string) map[string]any {
|
||||
switch name {
|
||||
case "page", "page_size", "limit", "offset", "days", "period_days":
|
||||
case "page", "page_size", "limit", "offset", "days", "period_days", "size":
|
||||
return map[string]any{"type": "integer"}
|
||||
case "brand_id", "keyword_id", "question_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version":
|
||||
return map[string]any{"type": "integer", "format": "int64"}
|
||||
case "force":
|
||||
return map[string]any{"type": "string", "enum": []string{"1"}}
|
||||
case "ungrouped", "undo":
|
||||
case "ungrouped", "undo", "recursive", "is_active":
|
||||
return map[string]any{"type": "string", "enum": []string{"true", "false"}}
|
||||
case "created_from", "created_to", "date_from", "date_to", "business_date":
|
||||
return map[string]any{"type": "string", "format": "date"}
|
||||
@@ -403,6 +414,9 @@ func isMultipartRoute(route gin.RouteInfo) bool {
|
||||
}
|
||||
path := route.Path
|
||||
return path == "/api/tenant/images" ||
|
||||
path == "/api/ops/site-domain-mappings/import" ||
|
||||
path == "/api/ops/object-storage/objects/upload" ||
|
||||
path == "/api/ops/desktop-client/releases/upload" ||
|
||||
path == "/api/tenant/knowledge/items/file" ||
|
||||
path == "/api/tenant/kol/manage/profile/avatar" ||
|
||||
path == "/api/tenant/articles/:id/images"
|
||||
|
||||
Reference in New Issue
Block a user