bef55bd11f
Open a separate pgx pool for the monitoring database, wire it into the SiteDomainMappingService, and surface its health in /readyz. Point compose at the dedicated monitoring-postgres service and align local/dev configs with the per-instance ports. Also reformat the site_domain_mapping types with gofmt while the files are touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
8.3 KiB
Go
256 lines
8.3 KiB
Go
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,
|
|
}
|
|
}
|