7d8e82c69f
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>
545 lines
17 KiB
Go
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,
|
|
}
|
|
}
|