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
@@ -44,6 +44,18 @@ type UpdateSiteDomainMappingInput struct {
IsActive bool
}
type UpsertSiteDomainMappingInput struct {
RegistrableDomain string
SiteKey string
SiteName string
IsActive bool
}
type UpsertSiteDomainMappingResult struct {
Item *domain.SiteDomainMapping
Created bool
}
func scanSiteDomainMapping(row pgx.Row) (*domain.SiteDomainMapping, error) {
var item domain.SiteDomainMapping
err := row.Scan(
@@ -64,7 +76,29 @@ func scanSiteDomainMapping(row pgx.Row) (*domain.SiteDomainMapping, error) {
return &item, nil
}
func (r *SiteDomainMappingRepository) List(ctx context.Context, f SiteDomainMappingFilter) ([]domain.SiteDomainMapping, int64, error) {
func scanSiteDomainMappingUpsert(row pgx.Row) (*UpsertSiteDomainMappingResult, error) {
var item domain.SiteDomainMapping
var created bool
err := row.Scan(
&item.ID,
&item.RegistrableDomain,
&item.SiteKey,
&item.SiteName,
&item.IsActive,
&item.CreatedAt,
&item.UpdatedAt,
&created,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSiteDomainMappingNotFound
}
return nil, err
}
return &UpsertSiteDomainMappingResult{Item: &item, Created: created}, nil
}
func buildSiteDomainMappingWhere(f SiteDomainMappingFilter) (string, []any) {
args := []any{}
where := "1=1"
@@ -78,6 +112,12 @@ func (r *SiteDomainMappingRepository) List(ctx context.Context, f SiteDomainMapp
where += fmt.Sprintf(" AND is_active = $%d", len(args))
}
return where, args
}
func (r *SiteDomainMappingRepository) List(ctx context.Context, f SiteDomainMappingFilter) ([]domain.SiteDomainMapping, int64, error) {
where, args := buildSiteDomainMappingWhere(f)
var total int64
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM site_domain_mappings WHERE "+where, args...).Scan(&total); err != nil {
return nil, 0, err
@@ -117,6 +157,37 @@ func (r *SiteDomainMappingRepository) List(ctx context.Context, f SiteDomainMapp
return items, total, rows.Err()
}
func (r *SiteDomainMappingRepository) Export(ctx context.Context, f SiteDomainMappingFilter) ([]domain.SiteDomainMapping, error) {
where, args := buildSiteDomainMappingWhere(f)
limit := f.Limit
if limit <= 0 {
limit = 10000
}
args = append(args, limit)
limitArg := len(args)
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT %s
FROM site_domain_mappings
WHERE %s
ORDER BY is_active DESC, registrable_domain ASC, id DESC
LIMIT $%d`, siteDomainMappingColumns, where, limitArg), args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]domain.SiteDomainMapping, 0, limit)
for rows.Next() {
item, scanErr := scanSiteDomainMapping(rows)
if scanErr != nil {
return nil, scanErr
}
items = append(items, *item)
}
return items, rows.Err()
}
func (r *SiteDomainMappingRepository) GetByID(ctx context.Context, id int64) (*domain.SiteDomainMapping, error) {
return scanSiteDomainMapping(r.pool.QueryRow(ctx, `
SELECT `+siteDomainMappingColumns+`
@@ -145,6 +216,19 @@ func (r *SiteDomainMappingRepository) Update(ctx context.Context, id int64, in U
in.RegistrableDomain, in.SiteKey, in.SiteName, in.IsActive, id))
}
func (r *SiteDomainMappingRepository) Upsert(ctx context.Context, in UpsertSiteDomainMappingInput) (*UpsertSiteDomainMappingResult, error) {
return scanSiteDomainMappingUpsert(r.pool.QueryRow(ctx, `
INSERT INTO site_domain_mappings (registrable_domain, site_key, site_name, is_active)
VALUES ($1, $2, $3, $4)
ON CONFLICT (registrable_domain) DO UPDATE
SET site_key = EXCLUDED.site_key,
site_name = EXCLUDED.site_name,
is_active = EXCLUDED.is_active,
updated_at = NOW()
RETURNING `+siteDomainMappingColumns+`, xmax = 0`,
in.RegistrableDomain, in.SiteKey, in.SiteName, in.IsActive))
}
func (r *SiteDomainMappingRepository) SetActive(ctx context.Context, id int64, active bool) (*domain.SiteDomainMapping, error) {
return scanSiteDomainMapping(r.pool.QueryRow(ctx, `
UPDATE site_domain_mappings