初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user