account_service.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "time"
  6. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  8. )
  9. var (
  10. ErrAccountNotFound = infraerrors.NotFound("ACCOUNT_NOT_FOUND", "account not found")
  11. ErrAccountNilInput = infraerrors.BadRequest("ACCOUNT_NIL_INPUT", "account input cannot be nil")
  12. ErrAccountNotInFallback = infraerrors.BadRequest("ACCOUNT_NOT_IN_FALLBACK", "account is not in proxy fallback state")
  13. )
  14. const AccountListGroupUngrouped int64 = -1
  15. const AccountPrivacyModeUnsetFilter = "__unset__"
  16. // OAuthRefreshPageOptions describes one bounded, cursor-stable scan of OAuth
  17. // accounts. Candidate platforms are supplied by TokenRefreshService's refresher
  18. // registry so repository eligibility cannot drift from registered providers.
  19. type OAuthRefreshPageOptions struct {
  20. Platforms []string
  21. AfterID int64
  22. Limit int
  23. ActiveOnly bool
  24. IncludeSetupToken bool
  25. RequireRefreshToken bool
  26. ExcludeRetryCooldown bool
  27. }
  28. // OAuthRefreshCandidatePage keeps cursor metadata from the raw SQL ID page.
  29. // Hydration may legitimately lose a concurrently deleted row, but callers can
  30. // still advance past the raw page without truncating or duplicating the scan.
  31. type OAuthRefreshCandidatePage struct {
  32. Accounts []Account
  33. NextAfterID int64
  34. HasMore bool
  35. }
  36. // OAuthRefreshCandidatePager is intentionally narrower than AccountRepository.
  37. // Production refresh cycles fail closed when the repository does not implement
  38. // this bounded contract instead of silently falling back to an unpaged scan.
  39. type OAuthRefreshCandidatePager interface {
  40. ListOAuthRefreshCandidatePage(ctx context.Context, options OAuthRefreshPageOptions) (*OAuthRefreshCandidatePage, error)
  41. }
  42. type AccountRepository interface {
  43. Create(ctx context.Context, account *Account) error
  44. GetByID(ctx context.Context, id int64) (*Account, error)
  45. // GetByIDs fetches accounts by IDs in a single query.
  46. // It should return all accounts found (missing IDs are ignored).
  47. GetByIDs(ctx context.Context, ids []int64) ([]*Account, error)
  48. // ExistsByID 检查账号是否存在,仅返回布尔值,用于删除前的轻量级存在性检查
  49. ExistsByID(ctx context.Context, id int64) (bool, error)
  50. // GetByCRSAccountID finds an account previously synced from CRS.
  51. // Returns (nil, nil) if not found.
  52. GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error)
  53. // FindByExtraField 根据 extra 字段中的键值对查找账号
  54. FindByExtraField(ctx context.Context, key string, value any) ([]Account, error)
  55. // ListCRSAccountIDs returns a map of crs_account_id -> local account ID
  56. // for all accounts that have been synced from CRS.
  57. ListCRSAccountIDs(ctx context.Context) (map[string]int64, error)
  58. Update(ctx context.Context, account *Account) error
  59. Delete(ctx context.Context, id int64) error
  60. List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error)
  61. ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error)
  62. // ListAllWithFilters 返回符合过滤条件的全部账号(不分页),用于账号列表页
  63. // 计算 OpenAI 调度分数的过滤范围池。
  64. ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error)
  65. ListByGroup(ctx context.Context, groupID int64) ([]Account, error)
  66. ListActive(ctx context.Context) ([]Account, error)
  67. ListByPlatform(ctx context.Context, platform string) ([]Account, error)
  68. UpdateLastUsed(ctx context.Context, id int64) error
  69. BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error
  70. SetError(ctx context.Context, id int64, errorMsg string) error
  71. ClearError(ctx context.Context, id int64) error
  72. SetSchedulable(ctx context.Context, id int64, schedulable bool) error
  73. AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error)
  74. BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
  75. ListSchedulable(ctx context.Context) ([]Account, error)
  76. ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error)
  77. ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error)
  78. ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error)
  79. ListSchedulableByPlatforms(ctx context.Context, platforms []string) ([]Account, error)
  80. ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error)
  81. ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error)
  82. ListSchedulableUngroupedByPlatforms(ctx context.Context, platforms []string) ([]Account, error)
  83. // ListModelAvailabilityCandidates returns accounts that are enabled by
  84. // persistent configuration (active + schedulable) for model-support
  85. // diagnosis. It deliberately does not filter transient runtime state such
  86. // as rate-limit, overload, temporary-unschedulable, or expiry windows.
  87. // When groupID is nil, includeGrouped controls whether the query scans all
  88. // matching accounts or only accounts without a group binding.
  89. ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, platforms []string, includeGrouped bool) ([]Account, error)
  90. SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error
  91. SetModelRateLimit(ctx context.Context, id int64, scope string, resetAt time.Time, reason ...string) error
  92. SetOverloaded(ctx context.Context, id int64, until time.Time) error
  93. SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error
  94. ClearTempUnschedulable(ctx context.Context, id int64) error
  95. ClearRateLimit(ctx context.Context, id int64) error
  96. ClearAntigravityQuotaScopes(ctx context.Context, id int64) error
  97. ClearModelRateLimits(ctx context.Context, id int64) error
  98. UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error
  99. // UpdateSessionWindowEnd 仅更新 5h 窗口的结束时间,不动 start / status。
  100. // 用于 active poll 拿到新 ResetsAt 后回写,避免覆盖请求路径上记录的 status。
  101. UpdateSessionWindowEnd(ctx context.Context, id int64, end time.Time) error
  102. UpdateExtra(ctx context.Context, id int64, updates map[string]any) error
  103. BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error)
  104. // IncrementQuotaUsed 原子递增 API Key 账号的配额用量(总/日/周)
  105. IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error
  106. // ResetQuotaUsedAndClearRateLimitCooldown atomically resets API Key quota usage
  107. // and clears only the account-level rate-limit cooldown.
  108. ResetQuotaUsedAndClearRateLimitCooldown(ctx context.Context, id int64) error
  109. // RevertProxyFallback 将账号的 proxy_id 切回 proxy_fallback_origin_id,并清空 origin 字段。
  110. // 仅当 proxy_fallback_origin_id IS NOT NULL 时更新,否则视为账号不存在(返回 ErrAccountNotFound)。
  111. RevertProxyFallback(ctx context.Context, accountID int64) error
  112. // ListShadowsByParent 返回指定父账号的影子账号;当前实现仅查 quota_dimension='spark'(唯一预设)。
  113. // ⚠️ 新增影子维度时:须更新此函数(或新增维度专用列举),并检查所有调用点(级联删除/一母一影校验/type 守卫),否则会静默漏掉新维度。
  114. ListShadowsByParent(ctx context.Context, parentID int64) ([]*Account, error)
  115. }
  116. type AccountDuplicateRepository interface {
  117. // CreateWithAccountGroups atomically persists an account, its exact group priorities,
  118. // and the scheduler outbox event for the new routing snapshot.
  119. CreateWithAccountGroups(ctx context.Context, account *Account, groups []AccountGroup) error
  120. }
  121. // AccountBillingSettingsRepository applies an admin edit without overwriting a
  122. // rate_multiplier that a successful upstream probe synchronized after the edit
  123. // form was loaded. A nil rateMultiplier means the request did not edit it.
  124. type AccountBillingSettingsRepository interface {
  125. UpdateWithAccountBillingSettings(
  126. ctx context.Context,
  127. account *Account,
  128. probeEnabled *bool,
  129. rateSyncEnabled *bool,
  130. rateMultiplier *float64,
  131. ) error
  132. }
  133. // AdminAccountRepository makes the account-duplication write capability an explicit
  134. // construction dependency without forcing read-only gateway test doubles to implement it.
  135. type AdminAccountRepository interface {
  136. AccountRepository
  137. AccountDuplicateRepository
  138. AccountBillingSettingsRepository
  139. }
  140. // AccountBulkUpdate describes the fields that can be updated in a bulk operation.
  141. // Nil pointers mean "do not change".
  142. type AccountBulkUpdate struct {
  143. Name *string
  144. ProxyID *int64
  145. Concurrency *int
  146. Priority *int
  147. RateMultiplier *float64
  148. LoadFactor *int
  149. Status *string
  150. Schedulable *bool
  151. Credentials map[string]any
  152. Extra map[string]any
  153. ProbeEnabled *bool
  154. // EnsureCodexFingerprintSeed asks the repository to atomically preserve an
  155. // existing valid Codex fingerprint seed or create one for eligible rows.
  156. EnsureCodexFingerprintSeed bool
  157. }
  158. // CreateAccountRequest 创建账号请求
  159. type CreateAccountRequest struct {
  160. Name string `json:"name"`
  161. Notes *string `json:"notes"`
  162. Platform string `json:"platform"`
  163. Type string `json:"type"`
  164. Credentials map[string]any `json:"credentials"`
  165. Extra map[string]any `json:"extra"`
  166. ProxyID *int64 `json:"proxy_id"`
  167. Concurrency int `json:"concurrency"`
  168. Priority int `json:"priority"`
  169. GroupIDs []int64 `json:"group_ids"`
  170. ExpiresAt *time.Time `json:"expires_at"`
  171. AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
  172. }
  173. // UpdateAccountRequest 更新账号请求
  174. type UpdateAccountRequest struct {
  175. Name *string `json:"name"`
  176. Notes *string `json:"notes"`
  177. Credentials *map[string]any `json:"credentials"`
  178. Extra *map[string]any `json:"extra"`
  179. ProxyID *int64 `json:"proxy_id"`
  180. Concurrency *int `json:"concurrency"`
  181. Priority *int `json:"priority"`
  182. Status *string `json:"status"`
  183. GroupIDs *[]int64 `json:"group_ids"`
  184. ExpiresAt *time.Time `json:"expires_at"`
  185. AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
  186. }
  187. // AccountService 账号管理服务
  188. type AccountService struct {
  189. accountRepo AccountRepository
  190. groupRepo GroupRepository
  191. }
  192. type groupExistenceBatchChecker interface {
  193. ExistsByIDs(ctx context.Context, ids []int64) (map[int64]bool, error)
  194. }
  195. // NewAccountService 创建账号服务实例
  196. func NewAccountService(accountRepo AccountRepository, groupRepo GroupRepository) *AccountService {
  197. return &AccountService{
  198. accountRepo: accountRepo,
  199. groupRepo: groupRepo,
  200. }
  201. }
  202. // Create 创建账号
  203. func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (*Account, error) {
  204. // 验证分组是否存在(如果指定了分组)
  205. if len(req.GroupIDs) > 0 {
  206. if err := s.validateGroupIDsExist(ctx, req.GroupIDs); err != nil {
  207. return nil, err
  208. }
  209. }
  210. // 创建账号
  211. account := &Account{
  212. Name: req.Name,
  213. Notes: normalizeAccountNotes(req.Notes),
  214. Platform: req.Platform,
  215. Type: req.Type,
  216. Credentials: SanitizeStoredCredentials(req.Platform, req.Credentials),
  217. Extra: prepareCodexFingerprintExtraForCreate(req.Platform, req.Type, req.Extra),
  218. ProxyID: req.ProxyID,
  219. Concurrency: req.Concurrency,
  220. Priority: req.Priority,
  221. Status: StatusActive,
  222. ExpiresAt: req.ExpiresAt,
  223. }
  224. // Random proxy mode owns proxy selection at request time; never persist a
  225. // simultaneously supplied fixed proxy association.
  226. if account.IsRandomProxy() {
  227. account.ProxyID = nil
  228. }
  229. if req.AutoPauseOnExpired != nil {
  230. account.AutoPauseOnExpired = *req.AutoPauseOnExpired
  231. } else {
  232. account.AutoPauseOnExpired = true
  233. }
  234. if err := s.accountRepo.Create(ctx, account); err != nil {
  235. return nil, fmt.Errorf("create account: %w", err)
  236. }
  237. // require_oauth_only 检查:apikey 类型账号不可加入限制分组
  238. if account.Type == AccountTypeAPIKey && len(req.GroupIDs) > 0 {
  239. for _, gid := range req.GroupIDs {
  240. g, err := s.groupRepo.GetByID(ctx, gid)
  241. if err != nil {
  242. return nil, err
  243. }
  244. if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini || g.Platform == PlatformGrok) {
  245. return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
  246. }
  247. }
  248. }
  249. // 绑定分组
  250. if len(req.GroupIDs) > 0 {
  251. if err := s.accountRepo.BindGroups(ctx, account.ID, req.GroupIDs); err != nil {
  252. return nil, fmt.Errorf("bind groups: %w", err)
  253. }
  254. }
  255. return account, nil
  256. }
  257. // GetByID 根据ID获取账号
  258. func (s *AccountService) GetByID(ctx context.Context, id int64) (*Account, error) {
  259. account, err := s.accountRepo.GetByID(ctx, id)
  260. if err != nil {
  261. return nil, fmt.Errorf("get account: %w", err)
  262. }
  263. return account, nil
  264. }
  265. // List 获取账号列表
  266. func (s *AccountService) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
  267. accounts, pagination, err := s.accountRepo.List(ctx, params)
  268. if err != nil {
  269. return nil, nil, fmt.Errorf("list accounts: %w", err)
  270. }
  271. return accounts, pagination, nil
  272. }
  273. // ListByPlatform 根据平台获取账号列表
  274. func (s *AccountService) ListByPlatform(ctx context.Context, platform string) ([]Account, error) {
  275. accounts, err := s.accountRepo.ListByPlatform(ctx, platform)
  276. if err != nil {
  277. return nil, fmt.Errorf("list accounts by platform: %w", err)
  278. }
  279. return accounts, nil
  280. }
  281. // ListByGroup 根据分组获取账号列表
  282. func (s *AccountService) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
  283. accounts, err := s.accountRepo.ListByGroup(ctx, groupID)
  284. if err != nil {
  285. return nil, fmt.Errorf("list accounts by group: %w", err)
  286. }
  287. return accounts, nil
  288. }
  289. // Update 更新账号
  290. func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccountRequest) (*Account, error) {
  291. account, err := s.accountRepo.GetByID(ctx, id)
  292. if err != nil {
  293. return nil, fmt.Errorf("get account: %w", err)
  294. }
  295. // 更新字段
  296. if req.Name != nil {
  297. account.Name = *req.Name
  298. }
  299. if req.Notes != nil {
  300. account.Notes = normalizeAccountNotes(req.Notes)
  301. }
  302. if req.Credentials != nil {
  303. account.Credentials = SanitizeStoredCredentials(account.Platform, *req.Credentials)
  304. }
  305. if req.Extra != nil {
  306. extra := make(map[string]any, len(*req.Extra))
  307. for key, value := range *req.Extra {
  308. extra[key] = value
  309. }
  310. delete(extra, OllamaCloudUsageSessionExtraKey)
  311. delete(extra, OllamaCloudUsageAutoRefreshExtraKey)
  312. delete(extra, OllamaCloudUsageSnapshotExtraKey)
  313. account.Extra = prepareCodexFingerprintExtraForUpdate(account, NormalizeProxyModeExtra(extra))
  314. } else {
  315. account.Extra = prepareCodexFingerprintExtraForUpdate(account, NormalizeProxyModeExtra(account.Extra))
  316. }
  317. if req.ProxyID != nil {
  318. // A zero proxy ID means clear the fixed association. Random mode is
  319. // retained only when this request explicitly carried proxy_mode=random;
  320. // otherwise changing the proxy also disables random selection.
  321. randomProxyRequested := req.Extra != nil && account.IsRandomProxy()
  322. if *req.ProxyID <= 0 {
  323. account.ProxyID = nil
  324. if !randomProxyRequested && account.Extra != nil {
  325. delete(account.Extra, ProxyModeExtraKey)
  326. }
  327. } else {
  328. account.ProxyID = req.ProxyID
  329. if account.Extra != nil {
  330. delete(account.Extra, ProxyModeExtraKey)
  331. }
  332. }
  333. }
  334. if req.Concurrency != nil {
  335. account.Concurrency = *req.Concurrency
  336. }
  337. if req.Priority != nil {
  338. account.Priority = *req.Priority
  339. }
  340. if req.Status != nil {
  341. account.Status = *req.Status
  342. }
  343. if req.ExpiresAt != nil {
  344. account.ExpiresAt = req.ExpiresAt
  345. }
  346. if req.AutoPauseOnExpired != nil {
  347. account.AutoPauseOnExpired = *req.AutoPauseOnExpired
  348. }
  349. // 先验证分组是否存在(在任何写操作之前)
  350. if req.GroupIDs != nil {
  351. if err := s.validateGroupIDsExist(ctx, *req.GroupIDs); err != nil {
  352. return nil, err
  353. }
  354. }
  355. // 执行更新
  356. if err := s.accountRepo.Update(ctx, account); err != nil {
  357. return nil, fmt.Errorf("update account: %w", err)
  358. }
  359. // require_oauth_only 检查
  360. if account.Type == AccountTypeAPIKey && req.GroupIDs != nil {
  361. for _, gid := range *req.GroupIDs {
  362. g, err := s.groupRepo.GetByID(ctx, gid)
  363. if err != nil {
  364. return nil, err
  365. }
  366. if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini || g.Platform == PlatformGrok) {
  367. return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
  368. }
  369. }
  370. }
  371. // 绑定分组
  372. if req.GroupIDs != nil {
  373. if err := s.accountRepo.BindGroups(ctx, account.ID, *req.GroupIDs); err != nil {
  374. return nil, fmt.Errorf("bind groups: %w", err)
  375. }
  376. }
  377. return account, nil
  378. }
  379. // Delete 删除账号
  380. // 优化:使用 ExistsByID 替代 GetByID 进行存在性检查,
  381. // 避免加载完整账号对象及其关联数据,提升删除操作的性能
  382. func (s *AccountService) Delete(ctx context.Context, id int64) error {
  383. // 使用轻量级的存在性检查,而非加载完整账号对象
  384. exists, err := s.accountRepo.ExistsByID(ctx, id)
  385. if err != nil {
  386. return fmt.Errorf("check account: %w", err)
  387. }
  388. // 明确返回账号不存在错误,便于调用方区分错误类型
  389. if !exists {
  390. return ErrAccountNotFound
  391. }
  392. // 注意:此处不级联删除 spark 影子账号。当前唯一的后台删除入口走 AdminService.DeleteAccount
  393. // (已 ListShadowsByParent 先删影子再删母)。本方法目前无删除调用方;若未来有调用方经此
  394. // 删除母账号,需在此补级联,否则会留下孤儿影子(外审第6轮 P3:当前不可达,记为残留)。
  395. if err := s.accountRepo.Delete(ctx, id); err != nil {
  396. return fmt.Errorf("delete account: %w", err)
  397. }
  398. return nil
  399. }
  400. func (s *AccountService) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error {
  401. if len(groupIDs) == 0 {
  402. return nil
  403. }
  404. if s.groupRepo == nil {
  405. return fmt.Errorf("group repository not configured")
  406. }
  407. if batchChecker, ok := s.groupRepo.(groupExistenceBatchChecker); ok {
  408. existsByID, err := batchChecker.ExistsByIDs(ctx, groupIDs)
  409. if err != nil {
  410. return fmt.Errorf("check groups exists: %w", err)
  411. }
  412. for _, groupID := range groupIDs {
  413. if groupID <= 0 {
  414. return fmt.Errorf("get group: %w", ErrGroupNotFound)
  415. }
  416. if !existsByID[groupID] {
  417. return fmt.Errorf("get group: %w", ErrGroupNotFound)
  418. }
  419. }
  420. return nil
  421. }
  422. for _, groupID := range groupIDs {
  423. _, err := s.groupRepo.GetByID(ctx, groupID)
  424. if err != nil {
  425. return fmt.Errorf("get group: %w", err)
  426. }
  427. }
  428. return nil
  429. }
  430. // UpdateStatus 更新账号状态
  431. func (s *AccountService) UpdateStatus(ctx context.Context, id int64, status string, errorMessage string) error {
  432. account, err := s.accountRepo.GetByID(ctx, id)
  433. if err != nil {
  434. return fmt.Errorf("get account: %w", err)
  435. }
  436. account.Status = status
  437. account.ErrorMessage = errorMessage
  438. if err := s.accountRepo.Update(ctx, account); err != nil {
  439. return fmt.Errorf("update account: %w", err)
  440. }
  441. return nil
  442. }
  443. // UpdateLastUsed 更新最后使用时间
  444. func (s *AccountService) UpdateLastUsed(ctx context.Context, id int64) error {
  445. if err := s.accountRepo.UpdateLastUsed(ctx, id); err != nil {
  446. return fmt.Errorf("update last used: %w", err)
  447. }
  448. return nil
  449. }
  450. // GetCredential 获取账号凭证(安全访问)
  451. func (s *AccountService) GetCredential(ctx context.Context, id int64, key string) (string, error) {
  452. account, err := s.accountRepo.GetByID(ctx, id)
  453. if err != nil {
  454. return "", fmt.Errorf("get account: %w", err)
  455. }
  456. return account.GetCredential(key), nil
  457. }
  458. // TestCredentials 测试账号凭证是否有效(需要实现具体平台的测试逻辑)
  459. func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
  460. account, err := s.accountRepo.GetByID(ctx, id)
  461. if err != nil {
  462. return fmt.Errorf("get account: %w", err)
  463. }
  464. // 根据平台执行不同的测试逻辑
  465. switch account.Platform {
  466. case PlatformAnthropic:
  467. // TODO: 测试Anthropic API凭证
  468. return nil
  469. case PlatformOpenAI:
  470. // TODO: 测试OpenAI API凭证
  471. return nil
  472. case PlatformGemini:
  473. // TODO: 测试Gemini API凭证
  474. return nil
  475. case PlatformGrok:
  476. // Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
  477. return nil
  478. case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
  479. // 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
  480. return nil
  481. default:
  482. return fmt.Errorf("unsupported platform: %s", account.Platform)
  483. }
  484. }