package service import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "gorm.io/gorm" "oci-portal/internal/crypto" "oci-portal/internal/model" "oci-portal/internal/oci" ) // 代理配置错误;api 层映射 400 / 409。 var ( // ErrProxyInvalid 表示字段校验失败。 ErrProxyInvalid = errors.New("代理配置非法") // ErrProxyInUse 表示代理仍被租户引用,拒绝删除。 ErrProxyInUse = errors.New("代理仍被租户关联,请先解除关联") ) // ProxyService 管理出站代理配置;密码 AES-GCM 加密落库。 type ProxyService struct { db *gorm.DB cipher *crypto.Cipher // wg 追踪创建 / 更新后的异步出口地理探测,Wait 供优雅关停与测试收敛 wg sync.WaitGroup // geoClientFor 构造经代理出站的探测 client;测试注入替身避免真实外呼 geoClientFor func(*oci.ProxySpec) *http.Client } // NewProxyService 组装依赖。 func NewProxyService(db *gorm.DB, cipher *crypto.Cipher) *ProxyService { return &ProxyService{db: db, cipher: cipher, geoClientFor: oci.HTTPClientFor} } // Wait 阻塞至在途的地理探测全部完成;进程退出前调用。 func (s *ProxyService) Wait() { s.wg.Wait() } // ProxyView 是代理的脱敏视图,绝不含密码明文。 type ProxyView struct { ID uint `json:"id"` Name string `json:"name"` Type string `json:"type"` Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` PasswordSet bool `json:"passwordSet"` // Country / City 为出口实测地区;GeoAt 空串表示尚未探测(前端显示「检测中」) Country string `json:"country"` City string `json:"city"` GeoAt string `json:"geoAt"` // UsedBy 为关联此代理的租户数,前端据此提示删除约束 UsedBy int64 `json:"usedBy"` CreatedAt string `json:"createdAt"` } // ProxyInput 是创建 / 更新请求体;Name 缺省自动生成, // Password 为 nil 表示沿用已存值,空串表示清除。 type ProxyInput struct { Name string `json:"name"` Type string `json:"type" binding:"required"` Host string `json:"host" binding:"required"` Port int `json:"port" binding:"required"` Username string `json:"username"` Password *string `json:"password"` } // validateProxyInput 校验类型 / 端口 / 主机;错误信息用户可读。 func validateProxyInput(in ProxyInput) error { if in.Type != "socks5" && in.Type != "http" && in.Type != "https" { return fmt.Errorf("类型仅支持 socks5 / http / https: %w", ErrProxyInvalid) } if in.Port < 1 || in.Port > 65535 { return fmt.Errorf("端口须在 1-65535 之间: %w", ErrProxyInvalid) } if strings.TrimSpace(in.Host) == "" { return fmt.Errorf("主机不能为空: %w", ErrProxyInvalid) } return nil } // List 返回全部代理的脱敏视图,附带引用计数。 func (s *ProxyService) List(ctx context.Context) ([]ProxyView, error) { rows := []model.Proxy{} if err := s.db.WithContext(ctx).Order("id").Find(&rows).Error; err != nil { return nil, fmt.Errorf("list proxies: %w", err) } out := make([]ProxyView, 0, len(rows)) for i := range rows { v, err := s.viewOf(ctx, &rows[i]) if err != nil { return nil, err } out = append(out, v) } return out, nil } // viewOf 组装单条脱敏视图。 func (s *ProxyService) viewOf(ctx context.Context, p *model.Proxy) (ProxyView, error) { var used int64 err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("proxy_id = ?", p.ID).Count(&used).Error if err != nil { return ProxyView{}, fmt.Errorf("count proxy refs: %w", err) } return ProxyView{ ID: p.ID, Name: p.Name, Type: p.Type, Host: p.Host, Port: p.Port, Username: p.Username, PasswordSet: p.PasswordEnc != "", UsedBy: used, Country: p.Country, City: p.City, GeoAt: formatTimePtr(p.GeoAt), CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), }, nil } // formatTimePtr 把可空时间格式化为 RFC3339,nil 返回空串。 func formatTimePtr(t *time.Time) string { if t == nil { return "" } return t.Format("2006-01-02T15:04:05Z07:00") } // autoProxyName 名称缺省时生成 `{type}-{host}:{port}`,与现有代理重名时追加序号后缀。 func (s *ProxyService) autoProxyName(ctx context.Context, in ProxyInput, selfID uint) string { base := fmt.Sprintf("%s-%s:%d", in.Type, strings.TrimSpace(in.Host), in.Port) if len(base) > 56 { base = base[:56] } name := base for i := 2; i <= 999; i++ { var n int64 s.db.WithContext(ctx).Model(&model.Proxy{}). Where("name = ? AND id <> ?", name, selfID).Count(&n) if n == 0 { return name } name = fmt.Sprintf("%s-%d", base, i) } return name } // Create 新建代理;名称唯一冲突返回可读错误,成功后异步探测出口地区。 func (s *ProxyService) Create(ctx context.Context, in ProxyInput) (ProxyView, error) { if err := validateProxyInput(in); err != nil { return ProxyView{}, err } if strings.TrimSpace(in.Name) == "" { in.Name = s.autoProxyName(ctx, in, 0) } row := model.Proxy{ Name: strings.TrimSpace(in.Name), Type: in.Type, Host: strings.TrimSpace(in.Host), Port: in.Port, Username: in.Username, } if err := s.fillPassword(&row, in.Password); err != nil { return ProxyView{}, err } if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { return ProxyView{}, fmt.Errorf("create proxy: %w", err) } s.probeGeoAsync(row.ID) return s.viewOf(ctx, &row) } // Update 更新代理;密码缺省沿用、空串清除,名称留空重新自动生成,成功后重测地区。 func (s *ProxyService) Update(ctx context.Context, id uint, in ProxyInput) (ProxyView, error) { if err := validateProxyInput(in); err != nil { return ProxyView{}, err } if strings.TrimSpace(in.Name) == "" { in.Name = s.autoProxyName(ctx, in, id) } var row model.Proxy if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err) } row.Name, row.Type = strings.TrimSpace(in.Name), in.Type row.Host, row.Port, row.Username = strings.TrimSpace(in.Host), in.Port, in.Username if err := s.fillPassword(&row, in.Password); err != nil { return ProxyView{}, err } if err := s.db.WithContext(ctx).Save(&row).Error; err != nil { return ProxyView{}, fmt.Errorf("update proxy: %w", err) } s.probeGeoAsync(row.ID) return s.viewOf(ctx, &row) } // fillPassword 按输入语义写密文:nil 沿用、空串清除、非空加密覆盖。 func (s *ProxyService) fillPassword(row *model.Proxy, password *string) error { if password == nil { return nil } if *password == "" { row.PasswordEnc = "" return nil } enc, err := s.cipher.EncryptString(*password) if err != nil { return fmt.Errorf("encrypt proxy password: %w", err) } row.PasswordEnc = enc return nil } // Delete 删除代理;仍被租户引用时拒绝。 func (s *ProxyService) Delete(ctx context.Context, id uint) error { var used int64 err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("proxy_id = ?", id).Count(&used).Error if err != nil { return fmt.Errorf("count proxy refs: %w", err) } if used > 0 { return ErrProxyInUse } res := s.db.WithContext(ctx).Delete(&model.Proxy{}, id) if res.Error != nil { return fmt.Errorf("delete proxy: %w", res.Error) } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } // SetTenants 设置关联此代理的租户全集:列表内的租户改挂到本代理(含从 // 其他代理改挂),列表外已关联的解除;整体事务生效,返回新的关联计数。 func (s *ProxyService) SetTenants(ctx context.Context, id uint, cfgIDs []uint) (int64, error) { if err := s.db.WithContext(ctx).First(&model.Proxy{}, id).Error; err != nil { return 0, fmt.Errorf("find proxy %d: %w", id, err) } ids, err := s.validTenantIDs(ctx, cfgIDs) if err != nil { return 0, err } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { unbind := tx.Model(&model.OciConfig{}).Where("proxy_id = ?", id) if len(ids) > 0 { unbind = unbind.Where("id NOT IN ?", ids) } if err := unbind.Update("proxy_id", nil).Error; err != nil { return fmt.Errorf("unbind tenants: %w", err) } if len(ids) == 0 { return nil } err := tx.Model(&model.OciConfig{}).Where("id IN ?", ids).Update("proxy_id", id).Error if err != nil { return fmt.Errorf("bind tenants: %w", err) } return nil }) if err != nil { return 0, err } return int64(len(ids)), nil } // validTenantIDs 去重并校验租户 ID 均存在;含无效 ID 时报 ErrProxyInvalid。 func (s *ProxyService) validTenantIDs(ctx context.Context, cfgIDs []uint) ([]uint, error) { seen := map[uint]struct{}{} ids := make([]uint, 0, len(cfgIDs)) for _, v := range cfgIDs { if _, ok := seen[v]; !ok { seen[v] = struct{}{} ids = append(ids, v) } } if len(ids) == 0 { return ids, nil } var n int64 err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("id IN ?", ids).Count(&n).Error if err != nil { return nil, fmt.Errorf("count tenants: %w", err) } if n != int64(len(ids)) { return nil, fmt.Errorf("存在无效的租户 ID: %w", ErrProxyInvalid) } return ids, nil } // SpecOf 解密并组装 SDK 用的代理参数;id 为 nil 返回 nil(直连)。 func (s *ProxyService) SpecOf(ctx context.Context, id *uint) (*oci.ProxySpec, error) { if id == nil { return nil, nil } var row model.Proxy if err := s.db.WithContext(ctx).First(&row, *id).Error; err != nil { return nil, fmt.Errorf("find proxy %d: %w", *id, err) } password := "" if row.PasswordEnc != "" { p, err := s.cipher.DecryptString(row.PasswordEnc) if err != nil { return nil, fmt.Errorf("decrypt proxy password: %w", err) } password = p } return &oci.ProxySpec{ Type: row.Type, Host: row.Host, Port: row.Port, Username: row.Username, Password: password, }, nil }