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, } }