| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543 |
- package service
- import (
- "context"
- "fmt"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- )
- var (
- ErrAccountNotFound = infraerrors.NotFound("ACCOUNT_NOT_FOUND", "account not found")
- ErrAccountNilInput = infraerrors.BadRequest("ACCOUNT_NIL_INPUT", "account input cannot be nil")
- ErrAccountNotInFallback = infraerrors.BadRequest("ACCOUNT_NOT_IN_FALLBACK", "account is not in proxy fallback state")
- )
- const AccountListGroupUngrouped int64 = -1
- const AccountPrivacyModeUnsetFilter = "__unset__"
- // OAuthRefreshPageOptions describes one bounded, cursor-stable scan of OAuth
- // accounts. Candidate platforms are supplied by TokenRefreshService's refresher
- // registry so repository eligibility cannot drift from registered providers.
- type OAuthRefreshPageOptions struct {
- Platforms []string
- AfterID int64
- Limit int
- ActiveOnly bool
- IncludeSetupToken bool
- RequireRefreshToken bool
- ExcludeRetryCooldown bool
- }
- // OAuthRefreshCandidatePage keeps cursor metadata from the raw SQL ID page.
- // Hydration may legitimately lose a concurrently deleted row, but callers can
- // still advance past the raw page without truncating or duplicating the scan.
- type OAuthRefreshCandidatePage struct {
- Accounts []Account
- NextAfterID int64
- HasMore bool
- }
- // OAuthRefreshCandidatePager is intentionally narrower than AccountRepository.
- // Production refresh cycles fail closed when the repository does not implement
- // this bounded contract instead of silently falling back to an unpaged scan.
- type OAuthRefreshCandidatePager interface {
- ListOAuthRefreshCandidatePage(ctx context.Context, options OAuthRefreshPageOptions) (*OAuthRefreshCandidatePage, error)
- }
- type AccountRepository interface {
- Create(ctx context.Context, account *Account) error
- GetByID(ctx context.Context, id int64) (*Account, error)
- // GetByIDs fetches accounts by IDs in a single query.
- // It should return all accounts found (missing IDs are ignored).
- GetByIDs(ctx context.Context, ids []int64) ([]*Account, error)
- // ExistsByID 检查账号是否存在,仅返回布尔值,用于删除前的轻量级存在性检查
- ExistsByID(ctx context.Context, id int64) (bool, error)
- // GetByCRSAccountID finds an account previously synced from CRS.
- // Returns (nil, nil) if not found.
- GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error)
- // FindByExtraField 根据 extra 字段中的键值对查找账号
- FindByExtraField(ctx context.Context, key string, value any) ([]Account, error)
- // ListCRSAccountIDs returns a map of crs_account_id -> local account ID
- // for all accounts that have been synced from CRS.
- ListCRSAccountIDs(ctx context.Context) (map[string]int64, error)
- Update(ctx context.Context, account *Account) error
- Delete(ctx context.Context, id int64) error
- List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error)
- ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error)
- // ListAllWithFilters 返回符合过滤条件的全部账号(不分页),用于账号列表页
- // 计算 OpenAI 调度分数的过滤范围池。
- ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error)
- ListByGroup(ctx context.Context, groupID int64) ([]Account, error)
- ListActive(ctx context.Context) ([]Account, error)
- ListByPlatform(ctx context.Context, platform string) ([]Account, error)
- UpdateLastUsed(ctx context.Context, id int64) error
- BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error
- SetError(ctx context.Context, id int64, errorMsg string) error
- ClearError(ctx context.Context, id int64) error
- SetSchedulable(ctx context.Context, id int64, schedulable bool) error
- AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error)
- BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
- ListSchedulable(ctx context.Context) ([]Account, error)
- ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error)
- ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error)
- ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error)
- ListSchedulableByPlatforms(ctx context.Context, platforms []string) ([]Account, error)
- ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error)
- ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error)
- ListSchedulableUngroupedByPlatforms(ctx context.Context, platforms []string) ([]Account, error)
- // ListModelAvailabilityCandidates returns accounts that are enabled by
- // persistent configuration (active + schedulable) for model-support
- // diagnosis. It deliberately does not filter transient runtime state such
- // as rate-limit, overload, temporary-unschedulable, or expiry windows.
- // When groupID is nil, includeGrouped controls whether the query scans all
- // matching accounts or only accounts without a group binding.
- ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, platforms []string, includeGrouped bool) ([]Account, error)
- SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error
- SetModelRateLimit(ctx context.Context, id int64, scope string, resetAt time.Time, reason ...string) error
- SetOverloaded(ctx context.Context, id int64, until time.Time) error
- SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error
- ClearTempUnschedulable(ctx context.Context, id int64) error
- ClearRateLimit(ctx context.Context, id int64) error
- ClearAntigravityQuotaScopes(ctx context.Context, id int64) error
- ClearModelRateLimits(ctx context.Context, id int64) error
- UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error
- // UpdateSessionWindowEnd 仅更新 5h 窗口的结束时间,不动 start / status。
- // 用于 active poll 拿到新 ResetsAt 后回写,避免覆盖请求路径上记录的 status。
- UpdateSessionWindowEnd(ctx context.Context, id int64, end time.Time) error
- UpdateExtra(ctx context.Context, id int64, updates map[string]any) error
- BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error)
- // IncrementQuotaUsed 原子递增 API Key 账号的配额用量(总/日/周)
- IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error
- // ResetQuotaUsedAndClearRateLimitCooldown atomically resets API Key quota usage
- // and clears only the account-level rate-limit cooldown.
- ResetQuotaUsedAndClearRateLimitCooldown(ctx context.Context, id int64) error
- // RevertProxyFallback 将账号的 proxy_id 切回 proxy_fallback_origin_id,并清空 origin 字段。
- // 仅当 proxy_fallback_origin_id IS NOT NULL 时更新,否则视为账号不存在(返回 ErrAccountNotFound)。
- RevertProxyFallback(ctx context.Context, accountID int64) error
- // ListShadowsByParent 返回指定父账号的影子账号;当前实现仅查 quota_dimension='spark'(唯一预设)。
- // ⚠️ 新增影子维度时:须更新此函数(或新增维度专用列举),并检查所有调用点(级联删除/一母一影校验/type 守卫),否则会静默漏掉新维度。
- ListShadowsByParent(ctx context.Context, parentID int64) ([]*Account, error)
- }
- type AccountDuplicateRepository interface {
- // CreateWithAccountGroups atomically persists an account, its exact group priorities,
- // and the scheduler outbox event for the new routing snapshot.
- CreateWithAccountGroups(ctx context.Context, account *Account, groups []AccountGroup) error
- }
- // AccountBillingSettingsRepository applies an admin edit without overwriting a
- // rate_multiplier that a successful upstream probe synchronized after the edit
- // form was loaded. A nil rateMultiplier means the request did not edit it.
- type AccountBillingSettingsRepository interface {
- UpdateWithAccountBillingSettings(
- ctx context.Context,
- account *Account,
- probeEnabled *bool,
- rateSyncEnabled *bool,
- rateMultiplier *float64,
- ) error
- }
- // AdminAccountRepository makes the account-duplication write capability an explicit
- // construction dependency without forcing read-only gateway test doubles to implement it.
- type AdminAccountRepository interface {
- AccountRepository
- AccountDuplicateRepository
- AccountBillingSettingsRepository
- }
- // AccountBulkUpdate describes the fields that can be updated in a bulk operation.
- // Nil pointers mean "do not change".
- type AccountBulkUpdate struct {
- Name *string
- ProxyID *int64
- Concurrency *int
- Priority *int
- RateMultiplier *float64
- LoadFactor *int
- Status *string
- Schedulable *bool
- Credentials map[string]any
- Extra map[string]any
- ProbeEnabled *bool
- // EnsureCodexFingerprintSeed asks the repository to atomically preserve an
- // existing valid Codex fingerprint seed or create one for eligible rows.
- EnsureCodexFingerprintSeed bool
- }
- // CreateAccountRequest 创建账号请求
- type CreateAccountRequest struct {
- Name string `json:"name"`
- Notes *string `json:"notes"`
- Platform string `json:"platform"`
- Type string `json:"type"`
- Credentials map[string]any `json:"credentials"`
- Extra map[string]any `json:"extra"`
- ProxyID *int64 `json:"proxy_id"`
- Concurrency int `json:"concurrency"`
- Priority int `json:"priority"`
- GroupIDs []int64 `json:"group_ids"`
- ExpiresAt *time.Time `json:"expires_at"`
- AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
- }
- // UpdateAccountRequest 更新账号请求
- type UpdateAccountRequest struct {
- Name *string `json:"name"`
- Notes *string `json:"notes"`
- Credentials *map[string]any `json:"credentials"`
- Extra *map[string]any `json:"extra"`
- ProxyID *int64 `json:"proxy_id"`
- Concurrency *int `json:"concurrency"`
- Priority *int `json:"priority"`
- Status *string `json:"status"`
- GroupIDs *[]int64 `json:"group_ids"`
- ExpiresAt *time.Time `json:"expires_at"`
- AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
- }
- // AccountService 账号管理服务
- type AccountService struct {
- accountRepo AccountRepository
- groupRepo GroupRepository
- }
- type groupExistenceBatchChecker interface {
- ExistsByIDs(ctx context.Context, ids []int64) (map[int64]bool, error)
- }
- // NewAccountService 创建账号服务实例
- func NewAccountService(accountRepo AccountRepository, groupRepo GroupRepository) *AccountService {
- return &AccountService{
- accountRepo: accountRepo,
- groupRepo: groupRepo,
- }
- }
- // Create 创建账号
- func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (*Account, error) {
- // 验证分组是否存在(如果指定了分组)
- if len(req.GroupIDs) > 0 {
- if err := s.validateGroupIDsExist(ctx, req.GroupIDs); err != nil {
- return nil, err
- }
- }
- // 创建账号
- account := &Account{
- Name: req.Name,
- Notes: normalizeAccountNotes(req.Notes),
- Platform: req.Platform,
- Type: req.Type,
- Credentials: SanitizeStoredCredentials(req.Platform, req.Credentials),
- Extra: prepareCodexFingerprintExtraForCreate(req.Platform, req.Type, req.Extra),
- ProxyID: req.ProxyID,
- Concurrency: req.Concurrency,
- Priority: req.Priority,
- Status: StatusActive,
- ExpiresAt: req.ExpiresAt,
- }
- // Random proxy mode owns proxy selection at request time; never persist a
- // simultaneously supplied fixed proxy association.
- if account.IsRandomProxy() {
- account.ProxyID = nil
- }
- if req.AutoPauseOnExpired != nil {
- account.AutoPauseOnExpired = *req.AutoPauseOnExpired
- } else {
- account.AutoPauseOnExpired = true
- }
- if err := s.accountRepo.Create(ctx, account); err != nil {
- return nil, fmt.Errorf("create account: %w", err)
- }
- // require_oauth_only 检查:apikey 类型账号不可加入限制分组
- if account.Type == AccountTypeAPIKey && len(req.GroupIDs) > 0 {
- for _, gid := range req.GroupIDs {
- g, err := s.groupRepo.GetByID(ctx, gid)
- if err != nil {
- return nil, err
- }
- if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini || g.Platform == PlatformGrok) {
- return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
- }
- }
- }
- // 绑定分组
- if len(req.GroupIDs) > 0 {
- if err := s.accountRepo.BindGroups(ctx, account.ID, req.GroupIDs); err != nil {
- return nil, fmt.Errorf("bind groups: %w", err)
- }
- }
- return account, nil
- }
- // GetByID 根据ID获取账号
- func (s *AccountService) GetByID(ctx context.Context, id int64) (*Account, error) {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return nil, fmt.Errorf("get account: %w", err)
- }
- return account, nil
- }
- // List 获取账号列表
- func (s *AccountService) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
- accounts, pagination, err := s.accountRepo.List(ctx, params)
- if err != nil {
- return nil, nil, fmt.Errorf("list accounts: %w", err)
- }
- return accounts, pagination, nil
- }
- // ListByPlatform 根据平台获取账号列表
- func (s *AccountService) ListByPlatform(ctx context.Context, platform string) ([]Account, error) {
- accounts, err := s.accountRepo.ListByPlatform(ctx, platform)
- if err != nil {
- return nil, fmt.Errorf("list accounts by platform: %w", err)
- }
- return accounts, nil
- }
- // ListByGroup 根据分组获取账号列表
- func (s *AccountService) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
- accounts, err := s.accountRepo.ListByGroup(ctx, groupID)
- if err != nil {
- return nil, fmt.Errorf("list accounts by group: %w", err)
- }
- return accounts, nil
- }
- // Update 更新账号
- func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccountRequest) (*Account, error) {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return nil, fmt.Errorf("get account: %w", err)
- }
- // 更新字段
- if req.Name != nil {
- account.Name = *req.Name
- }
- if req.Notes != nil {
- account.Notes = normalizeAccountNotes(req.Notes)
- }
- if req.Credentials != nil {
- account.Credentials = SanitizeStoredCredentials(account.Platform, *req.Credentials)
- }
- if req.Extra != nil {
- extra := make(map[string]any, len(*req.Extra))
- for key, value := range *req.Extra {
- extra[key] = value
- }
- delete(extra, OllamaCloudUsageSessionExtraKey)
- delete(extra, OllamaCloudUsageAutoRefreshExtraKey)
- delete(extra, OllamaCloudUsageSnapshotExtraKey)
- account.Extra = prepareCodexFingerprintExtraForUpdate(account, NormalizeProxyModeExtra(extra))
- } else {
- account.Extra = prepareCodexFingerprintExtraForUpdate(account, NormalizeProxyModeExtra(account.Extra))
- }
- if req.ProxyID != nil {
- // A zero proxy ID means clear the fixed association. Random mode is
- // retained only when this request explicitly carried proxy_mode=random;
- // otherwise changing the proxy also disables random selection.
- randomProxyRequested := req.Extra != nil && account.IsRandomProxy()
- if *req.ProxyID <= 0 {
- account.ProxyID = nil
- if !randomProxyRequested && account.Extra != nil {
- delete(account.Extra, ProxyModeExtraKey)
- }
- } else {
- account.ProxyID = req.ProxyID
- if account.Extra != nil {
- delete(account.Extra, ProxyModeExtraKey)
- }
- }
- }
- if req.Concurrency != nil {
- account.Concurrency = *req.Concurrency
- }
- if req.Priority != nil {
- account.Priority = *req.Priority
- }
- if req.Status != nil {
- account.Status = *req.Status
- }
- if req.ExpiresAt != nil {
- account.ExpiresAt = req.ExpiresAt
- }
- if req.AutoPauseOnExpired != nil {
- account.AutoPauseOnExpired = *req.AutoPauseOnExpired
- }
- // 先验证分组是否存在(在任何写操作之前)
- if req.GroupIDs != nil {
- if err := s.validateGroupIDsExist(ctx, *req.GroupIDs); err != nil {
- return nil, err
- }
- }
- // 执行更新
- if err := s.accountRepo.Update(ctx, account); err != nil {
- return nil, fmt.Errorf("update account: %w", err)
- }
- // require_oauth_only 检查
- if account.Type == AccountTypeAPIKey && req.GroupIDs != nil {
- for _, gid := range *req.GroupIDs {
- g, err := s.groupRepo.GetByID(ctx, gid)
- if err != nil {
- return nil, err
- }
- if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini || g.Platform == PlatformGrok) {
- return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
- }
- }
- }
- // 绑定分组
- if req.GroupIDs != nil {
- if err := s.accountRepo.BindGroups(ctx, account.ID, *req.GroupIDs); err != nil {
- return nil, fmt.Errorf("bind groups: %w", err)
- }
- }
- return account, nil
- }
- // Delete 删除账号
- // 优化:使用 ExistsByID 替代 GetByID 进行存在性检查,
- // 避免加载完整账号对象及其关联数据,提升删除操作的性能
- func (s *AccountService) Delete(ctx context.Context, id int64) error {
- // 使用轻量级的存在性检查,而非加载完整账号对象
- exists, err := s.accountRepo.ExistsByID(ctx, id)
- if err != nil {
- return fmt.Errorf("check account: %w", err)
- }
- // 明确返回账号不存在错误,便于调用方区分错误类型
- if !exists {
- return ErrAccountNotFound
- }
- // 注意:此处不级联删除 spark 影子账号。当前唯一的后台删除入口走 AdminService.DeleteAccount
- // (已 ListShadowsByParent 先删影子再删母)。本方法目前无删除调用方;若未来有调用方经此
- // 删除母账号,需在此补级联,否则会留下孤儿影子(外审第6轮 P3:当前不可达,记为残留)。
- if err := s.accountRepo.Delete(ctx, id); err != nil {
- return fmt.Errorf("delete account: %w", err)
- }
- return nil
- }
- func (s *AccountService) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error {
- if len(groupIDs) == 0 {
- return nil
- }
- if s.groupRepo == nil {
- return fmt.Errorf("group repository not configured")
- }
- if batchChecker, ok := s.groupRepo.(groupExistenceBatchChecker); ok {
- existsByID, err := batchChecker.ExistsByIDs(ctx, groupIDs)
- if err != nil {
- return fmt.Errorf("check groups exists: %w", err)
- }
- for _, groupID := range groupIDs {
- if groupID <= 0 {
- return fmt.Errorf("get group: %w", ErrGroupNotFound)
- }
- if !existsByID[groupID] {
- return fmt.Errorf("get group: %w", ErrGroupNotFound)
- }
- }
- return nil
- }
- for _, groupID := range groupIDs {
- _, err := s.groupRepo.GetByID(ctx, groupID)
- if err != nil {
- return fmt.Errorf("get group: %w", err)
- }
- }
- return nil
- }
- // UpdateStatus 更新账号状态
- func (s *AccountService) UpdateStatus(ctx context.Context, id int64, status string, errorMessage string) error {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return fmt.Errorf("get account: %w", err)
- }
- account.Status = status
- account.ErrorMessage = errorMessage
- if err := s.accountRepo.Update(ctx, account); err != nil {
- return fmt.Errorf("update account: %w", err)
- }
- return nil
- }
- // UpdateLastUsed 更新最后使用时间
- func (s *AccountService) UpdateLastUsed(ctx context.Context, id int64) error {
- if err := s.accountRepo.UpdateLastUsed(ctx, id); err != nil {
- return fmt.Errorf("update last used: %w", err)
- }
- return nil
- }
- // GetCredential 获取账号凭证(安全访问)
- func (s *AccountService) GetCredential(ctx context.Context, id int64, key string) (string, error) {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return "", fmt.Errorf("get account: %w", err)
- }
- return account.GetCredential(key), nil
- }
- // TestCredentials 测试账号凭证是否有效(需要实现具体平台的测试逻辑)
- func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil {
- return fmt.Errorf("get account: %w", err)
- }
- // 根据平台执行不同的测试逻辑
- switch account.Platform {
- case PlatformAnthropic:
- // TODO: 测试Anthropic API凭证
- return nil
- case PlatformOpenAI:
- // TODO: 测试OpenAI API凭证
- return nil
- case PlatformGemini:
- // TODO: 测试Gemini API凭证
- return nil
- case PlatformGrok:
- // Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
- return nil
- case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
- // 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
- return nil
- default:
- return fmt.Errorf("unsupported platform: %s", account.Platform)
- }
- }
|