Files
geo/server/internal/ops/app/site_domain_mapping.go
T
root 7d8e82c69f 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>
2026-05-26 01:19:01 +08:00

545 lines
17 KiB
Go

package app
import (
"bytes"
"context"
"encoding/csv"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/geo-platform/tenant-api/internal/ops/domain"
"github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const (
ActionSiteDomainMappingCreate = "site_domain_mapping.create"
ActionSiteDomainMappingUpdate = "site_domain_mapping.update"
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 {
mappings *repository.SiteDomainMappingRepository
audits *AuditService
}
func NewSiteDomainMappingService(mappings *repository.SiteDomainMappingRepository, audits *AuditService) *SiteDomainMappingService {
return &SiteDomainMappingService{mappings: mappings, audits: audits}
}
type SiteDomainMappingView struct {
ID int64 `json:"id"`
RegistrableDomain string `json:"registrable_domain"`
SiteKey string `json:"site_key"`
SiteName string `json:"site_name"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SiteDomainMappingListInput struct {
Keyword string
IsActive *bool
Page int
Size int
}
type SiteDomainMappingListResult struct {
Items []SiteDomainMappingView `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type SiteDomainMappingInput struct {
RegistrableDomain string
SiteKey string
SiteName string
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,
RegistrableDomain: item.RegistrableDomain,
SiteKey: item.SiteKey,
SiteName: item.SiteName,
IsActive: item.IsActive,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
func (s *SiteDomainMappingService) List(ctx context.Context, in SiteDomainMappingListInput) (*SiteDomainMappingListResult, error) {
page := in.Page
if page <= 0 {
page = 1
}
size := in.Size
if size <= 0 || size > 200 {
size = 20
}
items, total, err := s.mappings.List(ctx, repository.SiteDomainMappingFilter{
Keyword: strings.TrimSpace(in.Keyword),
IsActive: in.IsActive,
Limit: size,
Offset: (page - 1) * size,
})
if err != nil {
return nil, err
}
views := make([]SiteDomainMappingView, 0, len(items))
for i := range items {
views = append(views, toSiteDomainMappingView(&items[i]))
}
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 {
return nil, err
}
item, err := s.mappings.Create(ctx, repository.CreateSiteDomainMappingInput{
RegistrableDomain: normalized.RegistrableDomain,
SiteKey: normalized.SiteKey,
SiteName: normalized.SiteName,
IsActive: normalized.IsActive,
})
if err != nil {
if isDuplicateSiteDomainMapping(err) {
return nil, response.ErrConflict(40920, "duplicate_site_domain_mapping", "该域名映射已存在")
}
return nil, err
}
if actor != nil && s.audits != nil {
_ = s.audits.Append(ctx, actor.audit(ActionSiteDomainMappingCreate, "site_domain_mapping", item.ID, siteDomainMappingAuditMetadata(item)))
}
view := toSiteDomainMappingView(item)
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 {
return nil, err
}
item, err := s.mappings.Update(ctx, id, repository.UpdateSiteDomainMappingInput{
RegistrableDomain: normalized.RegistrableDomain,
SiteKey: normalized.SiteKey,
SiteName: normalized.SiteName,
IsActive: normalized.IsActive,
})
if err != nil {
if errors.Is(err, repository.ErrSiteDomainMappingNotFound) {
return nil, response.ErrNotFound(40420, "site_domain_mapping_not_found", "域名映射不存在")
}
if isDuplicateSiteDomainMapping(err) {
return nil, response.ErrConflict(40920, "duplicate_site_domain_mapping", "该域名映射已存在")
}
return nil, err
}
if actor != nil && s.audits != nil {
_ = s.audits.Append(ctx, actor.audit(ActionSiteDomainMappingUpdate, "site_domain_mapping", item.ID, siteDomainMappingAuditMetadata(item)))
}
view := toSiteDomainMappingView(item)
return &view, nil
}
func (s *SiteDomainMappingService) SetActive(ctx context.Context, actor *Actor, id int64, active bool) (*SiteDomainMappingView, error) {
item, err := s.mappings.SetActive(ctx, id, active)
if err != nil {
if errors.Is(err, repository.ErrSiteDomainMappingNotFound) {
return nil, response.ErrNotFound(40420, "site_domain_mapping_not_found", "域名映射不存在")
}
return nil, err
}
if actor != nil && s.audits != nil {
action := ActionSiteDomainMappingEnable
if !active {
action = ActionSiteDomainMappingDisable
}
_ = s.audits.Append(ctx, actor.audit(action, "site_domain_mapping", item.ID, siteDomainMappingAuditMetadata(item)))
}
view := toSiteDomainMappingView(item)
return &view, nil
}
func (s *SiteDomainMappingService) Delete(ctx context.Context, actor *Actor, id int64) error {
item, err := s.mappings.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrSiteDomainMappingNotFound) {
return response.ErrNotFound(40420, "site_domain_mapping_not_found", "域名映射不存在")
}
return err
}
if err := s.mappings.Delete(ctx, id); err != nil {
if errors.Is(err, repository.ErrSiteDomainMappingNotFound) {
return response.ErrNotFound(40420, "site_domain_mapping_not_found", "域名映射不存在")
}
return err
}
if actor != nil && s.audits != nil {
_ = s.audits.Append(ctx, actor.audit(ActionSiteDomainMappingDelete, "site_domain_mapping", id, siteDomainMappingAuditMetadata(item)))
}
return nil
}
func normalizeSiteDomainMappingInput(in SiteDomainMappingInput) (SiteDomainMappingInput, error) {
domainName := normalizeDomainName(in.RegistrableDomain)
if domainName == "" || len(domainName) > 255 || strings.ContainsAny(domainName, " /\\") || !strings.Contains(domainName, ".") {
return SiteDomainMappingInput{}, response.ErrBadRequest(40050, "invalid_registrable_domain", "请输入有效域名")
}
siteKey := strings.ToLower(strings.TrimSpace(in.SiteKey))
if siteKey == "" {
siteKey = domainName
}
if len(siteKey) > 255 || strings.ContainsAny(siteKey, " /\\") {
return SiteDomainMappingInput{}, response.ErrBadRequest(40051, "invalid_site_key", "站点 Key 格式无效")
}
siteName := strings.TrimSpace(in.SiteName)
if siteName == "" || len([]rune(siteName)) > 255 {
return SiteDomainMappingInput{}, response.ErrBadRequest(40052, "invalid_site_name", "中文名不能为空且不能超过 255 个字")
}
return SiteDomainMappingInput{
RegistrableDomain: domainName,
SiteKey: siteKey,
SiteName: siteName,
IsActive: in.IsActive,
}, nil
}
func normalizeDomainName(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return ""
}
if strings.Contains(value, "://") {
if parsed, err := url.Parse(value); err == nil && parsed.Hostname() != "" {
value = parsed.Hostname()
}
} else if strings.ContainsAny(value, "/?#") {
if parsed, err := url.Parse("https://" + value); err == nil && parsed.Hostname() != "" {
value = parsed.Hostname()
}
} else if host, _, err := net.SplitHostPort(value); err == nil {
value = host
}
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"
}
func siteDomainMappingAuditMetadata(item *domain.SiteDomainMapping) map[string]any {
return map[string]any{
"registrable_domain": item.RegistrableDomain,
"site_key": item.SiteKey,
"site_name": item.SiteName,
"is_active": item.IsActive,
}
}