diff --git a/server/internal/ops/app/site_domain_mapping.go b/server/internal/ops/app/site_domain_mapping.go new file mode 100644 index 0000000..ae5c90c --- /dev/null +++ b/server/internal/ops/app/site_domain_mapping.go @@ -0,0 +1,255 @@ +package app + +import ( + "context" + "errors" + "net" + "net/url" + "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" +) + +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 +} + +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) 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) 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 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, + } +}