package service import ( "context" "fmt" "time" "gorm.io/gorm" "oci-portal/internal/cache" "oci-portal/internal/crypto" "oci-portal/internal/model" "oci-portal/internal/oci" ) // OciConfigService 管理 API Key 配置的导入、测活与快照同步。 type OciConfigService struct { db *gorm.DB cipher *crypto.Cipher client oci.Client // auditRaw 暂存审计原始事件(eventId → raw,TTL 10 分钟), // 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。 auditRaw *cache.Cache } // NewOciConfigService 组装依赖。 func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) *OciConfigService { return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)} } // ImportInput 是导入一份 API Key 的输入: // ConfigINI 与显式字段二选一(ConfigINI 优先),私钥内容必填。 type ImportInput struct { Alias string Group string ConfigINI string TenancyOCID string UserOCID string Region string Fingerprint string PrivateKey string Passphrase string // 多区域 / 多区间支持:开启后订阅区域与 compartment 入库缓存 MultiRegion bool MultiCompartment bool // ProxyID 关联出站代理,nil 直连 ProxyID *uint } // Changes 记录一次测活前后云端信息字段的变化,值为 [旧值, 新值]。 type Changes map[string][2]string // Import 保存一份新的 API Key 配置,随后立即测活并获取账户类别。 // 测活失败不回滚导入,结果记录在快照的状态字段里。 func (s *OciConfigService) Import(ctx context.Context, in ImportInput) (*model.OciConfig, error) { cred, err := s.buildCredentials(in) if err != nil { return nil, err } cfg, err := s.storeConfig(in.Alias, cred) if err != nil { return nil, err } cfg.Group = in.Group cfg.MultiRegion = in.MultiRegion cfg.MultiCompartment = in.MultiCompartment cfg.ProxyID = in.ProxyID if err := s.refresh(ctx, cfg, cred); err != nil { return nil, err } return cfg, nil } // Verify 重新测活并拉取云端信息,返回更新后的快照和字段变更集。 func (s *OciConfigService) Verify(ctx context.Context, id uint) (*model.OciConfig, Changes, error) { var cfg model.OciConfig if err := s.db.First(&cfg, id).Error; err != nil { return nil, nil, fmt.Errorf("find oci config %d: %w", id, err) } cred, err := s.credentialsOf(&cfg) if err != nil { return nil, nil, err } before := cfg if err := s.refresh(ctx, &cfg, cred); err != nil { return nil, nil, err } return &cfg, diffSnapshots(before, cfg), nil } // UpdateInput 是修改配置的输入;空字段保持不变。 // 替换私钥时必须同时提供新指纹;Passphrase 非 nil 时整体覆盖(空串表示清除口令)。 // Group 非 nil 时整体覆盖(空串表示取消分组)。 // MultiRegion / MultiCompartment 非 nil 时切换开关,开启后随本次测活同步缓存。 type UpdateInput struct { Alias string Group *string Region string Fingerprint string PrivateKey string Passphrase *string MultiRegion *bool MultiCompartment *bool // ProxyID 非 nil 时切换关联:0 表示解除代理,>0 表示关联到该代理 ProxyID *uint } // Update 修改配置的别名 / 默认区域 / 凭据字段,随后立即重新测活。 func (s *OciConfigService) Update(ctx context.Context, id uint, in UpdateInput) (*model.OciConfig, Changes, error) { if in.PrivateKey != "" && in.Fingerprint == "" { return nil, nil, fmt.Errorf("update oci config: fingerprint is required when replacing private key") } var cfg model.OciConfig if err := s.db.WithContext(ctx).First(&cfg, id).Error; err != nil { return nil, nil, fmt.Errorf("find oci config %d: %w", id, err) } if err := s.applyUpdate(&cfg, in); err != nil { return nil, nil, err } cred, err := s.credentialsOf(&cfg) if err != nil { return nil, nil, err } before := cfg if err := s.refresh(ctx, &cfg, cred); err != nil { return nil, nil, err } return &cfg, diffSnapshots(before, cfg), nil } // applyUpdate 把非空输入写进快照字段,凭据字段交由 applyCredentialUpdate 加密。 func (s *OciConfigService) applyUpdate(cfg *model.OciConfig, in UpdateInput) error { if in.Alias != "" { cfg.Alias = in.Alias } if in.Group != nil { cfg.Group = *in.Group } if in.Region != "" { cfg.Region = in.Region } if in.Fingerprint != "" { cfg.Fingerprint = in.Fingerprint } if in.MultiRegion != nil { cfg.MultiRegion = *in.MultiRegion } if in.MultiCompartment != nil { cfg.MultiCompartment = *in.MultiCompartment } if in.ProxyID != nil { if *in.ProxyID == 0 { cfg.ProxyID = nil } else { cfg.ProxyID = in.ProxyID } } return s.applyCredentialUpdate(cfg, in) } // applyCredentialUpdate 重新加密私钥与口令;Passphrase 非 nil 时整体覆盖。 func (s *OciConfigService) applyCredentialUpdate(cfg *model.OciConfig, in UpdateInput) error { if in.PrivateKey != "" { enc, err := s.cipher.EncryptString(in.PrivateKey) if err != nil { return fmt.Errorf("encrypt private key: %w", err) } cfg.PrivateKeyEnc = enc } if in.Passphrase == nil { return nil } cfg.PassphraseEnc = "" if *in.Passphrase != "" { enc, err := s.cipher.EncryptString(*in.Passphrase) if err != nil { return fmt.Errorf("encrypt passphrase: %w", err) } cfg.PassphraseEnc = enc } return nil } // ConfigSummary 是列表接口的瘦身视图:只含列表页与全局作用域选择器 // 消费的字段,订阅 / 促销 / 凭据元数据等详情字段走 Get 单查。 type ConfigSummary struct { ID uint `json:"id"` Alias string `json:"alias"` Group string `json:"group"` TenancyOCID string `json:"tenancyOcid"` TenancyName string `json:"tenancyName"` Region string `json:"region"` AccountType string `json:"accountType"` AliveStatus string `json:"aliveStatus"` LastError string `json:"lastError"` LastVerifiedAt *time.Time `json:"lastVerifiedAt"` MultiRegion bool `json:"multiRegion"` MultiCompartment bool `json:"multiCompartment"` ProxyID *uint `json:"proxyId"` // ProxyName 由 List 按关联填充,供表格代理列 hover 展示 ProxyName string `json:"proxyName"` CreatedAt time.Time `json:"createdAt"` } func toConfigSummary(cfg model.OciConfig) ConfigSummary { return ConfigSummary{ ID: cfg.ID, Alias: cfg.Alias, Group: cfg.Group, TenancyOCID: cfg.TenancyOCID, TenancyName: cfg.TenancyName, Region: cfg.Region, AccountType: cfg.AccountType, AliveStatus: cfg.AliveStatus, LastError: cfg.LastError, LastVerifiedAt: cfg.LastVerifiedAt, MultiRegion: cfg.MultiRegion, MultiCompartment: cfg.MultiCompartment, ProxyID: cfg.ProxyID, CreatedAt: cfg.CreatedAt, } } // List 返回全部配置的列表摘要。 func (s *OciConfigService) List(ctx context.Context) ([]ConfigSummary, error) { items := make([]model.OciConfig, 0) if err := s.db.WithContext(ctx).Order("id").Find(&items).Error; err != nil { return nil, fmt.Errorf("list oci configs: %w", err) } names, err := s.proxyNames(ctx) if err != nil { return nil, err } summaries := make([]ConfigSummary, len(items)) for i, cfg := range items { summaries[i] = toConfigSummary(cfg) if cfg.ProxyID != nil { summaries[i].ProxyName = names[*cfg.ProxyID] } } return summaries, nil } // proxyNames 一次取回代理 id → 名称映射,避免列表逐行查询。 func (s *OciConfigService) proxyNames(ctx context.Context) (map[uint]string, error) { rows := []model.Proxy{} if err := s.db.WithContext(ctx).Select("id", "name").Find(&rows).Error; err != nil { return nil, fmt.Errorf("list proxy names: %w", err) } out := make(map[uint]string, len(rows)) for _, r := range rows { out[r.ID] = r.Name } return out, nil } // Get 返回单个配置快照。 func (s *OciConfigService) Get(ctx context.Context, id uint) (*model.OciConfig, error) { var cfg model.OciConfig if err := s.db.WithContext(ctx).First(&cfg, id).Error; err != nil { return nil, fmt.Errorf("find oci config %d: %w", id, err) } return &cfg, nil } // Delete 删除配置及其区域 / 区间缓存。 func (s *OciConfigService) Delete(ctx context.Context, id uint) error { res := s.db.WithContext(ctx).Delete(&model.OciConfig{}, id) if res.Error != nil { return fmt.Errorf("delete oci config %d: %w", id, res.Error) } if res.RowsAffected == 0 { return fmt.Errorf("delete oci config %d: %w", id, gorm.ErrRecordNotFound) } s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.RegionCache{}) s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.CompartmentCache{}) return nil } // refresh 执行测活与账户信息拉取,并把最新云端状态同步进快照。 // OCI 调用失败体现在快照状态字段,只有本地存储失败才返回 error。 func (s *OciConfigService) refresh(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) error { now := time.Now() cfg.LastVerifiedAt = &now info, err := s.client.ValidateKey(ctx, cred) if err != nil { cfg.AliveStatus = model.AliveStatusDead cfg.LastError = oci.CompactError(err) } else { cfg.AliveStatus = model.AliveStatusAlive cfg.LastError = "" cfg.TenancyName = info.Name cfg.HomeRegionKey = info.HomeRegionKey s.applyProfile(ctx, cfg, cred) s.syncScopeCaches(ctx, cfg, cred) } if err := s.db.WithContext(ctx).Save(cfg).Error; err != nil { return fmt.Errorf("save oci config snapshot: %w", err) } return nil } // applyProfile 拉取订阅信息;失败只把类别降级为 unknown,不影响测活结论。 func (s *OciConfigService) applyProfile(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) { profile, err := s.client.FetchAccountProfile(ctx, cred) if err != nil { cfg.AccountType = model.AccountTypeUnknown cfg.LastError = fmt.Sprintf("fetch account profile: %s", oci.CompactError(err)) return } cfg.AccountType = profile.AccountType cfg.SubscriptionID = profile.SubscriptionID cfg.PaymentModel = profile.PaymentModel cfg.SubscriptionTier = profile.SubscriptionTier cfg.PromotionStatus = profile.PromotionStatus cfg.PromotionAmount = profile.PromotionAmount cfg.PromotionExpires = profile.PromotionExpires } func (s *OciConfigService) buildCredentials(in ImportInput) (oci.Credentials, error) { cred := oci.Credentials{ TenancyOCID: in.TenancyOCID, UserOCID: in.UserOCID, Region: in.Region, Fingerprint: in.Fingerprint, } if in.ConfigINI != "" { parsed, err := oci.ParseConfigINI(in.ConfigINI) if err != nil { return oci.Credentials{}, err } cred = parsed } cred.PrivateKey = in.PrivateKey if cred.Passphrase == "" { cred.Passphrase = in.Passphrase } if err := cred.Validate(); err != nil { return oci.Credentials{}, err } return cred, nil } func (s *OciConfigService) storeConfig(alias string, cred oci.Credentials) (*model.OciConfig, error) { keyEnc, err := s.cipher.EncryptString(cred.PrivateKey) if err != nil { return nil, fmt.Errorf("encrypt private key: %w", err) } passEnc := "" if cred.Passphrase != "" { if passEnc, err = s.cipher.EncryptString(cred.Passphrase); err != nil { return nil, fmt.Errorf("encrypt passphrase: %w", err) } } cfg := &model.OciConfig{ Alias: alias, UserOCID: cred.UserOCID, TenancyOCID: cred.TenancyOCID, Fingerprint: cred.Fingerprint, Region: cred.Region, PrivateKeyEnc: keyEnc, PassphraseEnc: passEnc, AccountType: model.AccountTypeUnknown, AliveStatus: model.AliveStatusUnknown, } if err := s.db.Create(cfg).Error; err != nil { return nil, fmt.Errorf("create oci config: %w", err) } return cfg, nil } // credentialsOf 从快照解密出完整凭据。 func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials, error) { key, err := s.cipher.DecryptString(cfg.PrivateKeyEnc) if err != nil { return oci.Credentials{}, fmt.Errorf("decrypt private key of config %d: %w", cfg.ID, err) } pass := "" if cfg.PassphraseEnc != "" { if pass, err = s.cipher.DecryptString(cfg.PassphraseEnc); err != nil { return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err) } } spec, err := s.proxySpecOf(cfg) if err != nil { return oci.Credentials{}, err } return oci.Credentials{ TenancyOCID: cfg.TenancyOCID, UserOCID: cfg.UserOCID, Region: cfg.Region, Fingerprint: cfg.Fingerprint, PrivateKey: key, Passphrase: pass, Proxy: spec, }, nil } // proxySpecOf 加载租户关联的出站代理;未关联返回 nil(直连)。 // 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。 func (s *OciConfigService) proxySpecOf(cfg *model.OciConfig) (*oci.ProxySpec, error) { if cfg.ProxyID == nil { return nil, nil } var row model.Proxy if err := s.db.First(&row, *cfg.ProxyID).Error; err != nil { return nil, fmt.Errorf("find proxy %d of config %d: %w", *cfg.ProxyID, cfg.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 } // diffSnapshots 找出两次快照间云端信息字段的变化。 func diffSnapshots(before, after model.OciConfig) Changes { fields := map[string][2]string{ "aliveStatus": {before.AliveStatus, after.AliveStatus}, "tenancyName": {before.TenancyName, after.TenancyName}, "homeRegionKey": {before.HomeRegionKey, after.HomeRegionKey}, "accountType": {before.AccountType, after.AccountType}, "subscriptionId": {before.SubscriptionID, after.SubscriptionID}, "paymentModel": {before.PaymentModel, after.PaymentModel}, "subscriptionTier": {before.SubscriptionTier, after.SubscriptionTier}, "promotionStatus": {before.PromotionStatus, after.PromotionStatus}, } changes := make(Changes) for name, pair := range fields { if pair[0] != pair[1] { changes[name] = pair } } return changes }