account_repo_ollama_cloud_usage.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. package repository
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "time"
  7. dbent "github.com/Wei-Shaw/sub2api/ent"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/lib/pq"
  10. )
  11. const (
  12. ollamaCloudBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://([wW][wW][wW]\.)?[oO][lL][lL][aA][mM][aA]\.[cC][oO][mM](:443)?(/v1)?$`
  13. ollamaCloudBaseURLMatchSQLPrefix = "btrim("
  14. ollamaCloudBaseURLMatchSQLSuffix = ") ~ '" + ollamaCloudBaseURLRegexSQL + "'"
  15. // ollamaCloudUsagePlatformsSQL 是 service.isOllamaCloudUsagePlatform 的 SQL
  16. // 镜像:Ollama Cloud key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台
  17. // 下复用。所有平台白名单 SQL 只允许引用本常量,不得各处重写字面量,防止漂移。
  18. ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'"
  19. ollamaCloudUsageEligibleSQL = `
  20. platform IN (` + ollamaCloudUsagePlatformsSQL + `)
  21. AND type = 'apikey'
  22. AND ` + ollamaCloudBaseURLMatchSQLPrefix + `credentials ->> 'base_url'` + ollamaCloudBaseURLMatchSQLSuffix + `
  23. AND jsonb_typeof(credentials -> 'api_key') = 'string'
  24. `
  25. )
  26. func ollamaCloudBaseURLMatchesSQL(expression string) string {
  27. return ollamaCloudBaseURLMatchSQLPrefix + expression + ollamaCloudBaseURLMatchSQLSuffix
  28. }
  29. // ListOllamaCloudUsageGroupAccounts resolves every sibling for all supplied
  30. // identities with one ID query and one batch hydration. API keys are query
  31. // parameters only; no derived shared key is persisted.
  32. func (r *accountRepository) ListOllamaCloudUsageGroupAccounts(ctx context.Context, accounts []*service.Account) ([]service.Account, error) {
  33. if r == nil || r.sql == nil {
  34. return nil, service.ErrOllamaCloudUsageUnavailable
  35. }
  36. keys := make([]string, 0, len(accounts))
  37. seen := make(map[string]struct{}, len(accounts))
  38. for _, account := range accounts {
  39. if !service.IsOllamaCloudUsageAccount(account) || account.Credentials == nil {
  40. continue
  41. }
  42. apiKey, ok := account.Credentials["api_key"].(string)
  43. if !ok || apiKey == "" {
  44. continue
  45. }
  46. if _, duplicate := seen[apiKey]; duplicate {
  47. continue
  48. }
  49. seen[apiKey] = struct{}{}
  50. keys = append(keys, apiKey)
  51. }
  52. if len(keys) == 0 {
  53. return []service.Account{}, nil
  54. }
  55. rows, err := r.sql.QueryContext(ctx, `
  56. SELECT id
  57. FROM accounts
  58. WHERE deleted_at IS NULL
  59. AND `+ollamaCloudUsageEligibleSQL+`
  60. AND credentials ->> 'api_key' = ANY($1)
  61. ORDER BY id
  62. `, pq.Array(keys))
  63. if err != nil {
  64. return nil, err
  65. }
  66. defer func() { _ = rows.Close() }()
  67. ids := make([]int64, 0, len(keys))
  68. for rows.Next() {
  69. var id int64
  70. if err := rows.Scan(&id); err != nil {
  71. return nil, err
  72. }
  73. ids = append(ids, id)
  74. }
  75. if err := rows.Err(); err != nil {
  76. return nil, err
  77. }
  78. hydrated, err := r.GetByIDs(ctx, ids)
  79. if err != nil {
  80. return nil, err
  81. }
  82. result := make([]service.Account, 0, len(hydrated))
  83. for _, account := range hydrated {
  84. if account != nil {
  85. result = append(result, *account)
  86. }
  87. }
  88. return result, nil
  89. }
  90. func (r *accountRepository) SaveOllamaCloudUsageSession(ctx context.Context, account *service.Account, ciphertext string, autoRefresh bool) error {
  91. return r.updateOllamaCloudUsageGroup(ctx, account, map[string]any{
  92. service.OllamaCloudUsageSessionExtraKey: ciphertext,
  93. service.OllamaCloudUsageAutoRefreshExtraKey: autoRefresh,
  94. }, false)
  95. }
  96. func (r *accountRepository) DeleteOllamaCloudUsageSession(ctx context.Context, account *service.Account) error {
  97. return r.updateOllamaCloudUsageGroup(ctx, account, map[string]any{}, false)
  98. }
  99. func (r *accountRepository) SetOllamaCloudUsageAutoRefresh(ctx context.Context, account *service.Account, enabled bool) error {
  100. if !ollamaCloudUsageAccountHasSession(account) {
  101. return service.ErrOllamaCloudUsageSessionRequired
  102. }
  103. payload := ollamaCloudUsageManagedPayload(account)
  104. payload[service.OllamaCloudUsageAutoRefreshExtraKey] = enabled
  105. return r.updateOllamaCloudUsageGroup(ctx, account, payload, true)
  106. }
  107. func (r *accountRepository) UpdateOllamaCloudUsageSnapshot(ctx context.Context, account *service.Account, snapshot *service.OllamaCloudUsageSnapshot) error {
  108. if account == nil || snapshot == nil {
  109. return service.ErrAccountNilInput
  110. }
  111. if !ollamaCloudUsageAccountHasSession(account) {
  112. return service.ErrOllamaCloudUsageSessionRequired
  113. }
  114. payload := ollamaCloudUsageManagedPayload(account)
  115. payload[service.OllamaCloudUsageSnapshotExtraKey] = snapshot
  116. return r.updateOllamaCloudUsageGroup(ctx, account, payload, true)
  117. }
  118. // DisableOllamaCloudUsageAutoRefresh is group-scoped and retains the loaded
  119. // identity CAS. It cannot disable a new group after the account changes key.
  120. func (r *accountRepository) DisableOllamaCloudUsageAutoRefresh(ctx context.Context, account *service.Account) error {
  121. if !ollamaCloudUsageAccountHasSession(account) {
  122. return service.ErrOllamaCloudUsageSessionRequired
  123. }
  124. payload := ollamaCloudUsageManagedPayload(account)
  125. payload[service.OllamaCloudUsageAutoRefreshExtraKey] = false
  126. delete(payload, service.OllamaCloudUsageSnapshotExtraKey)
  127. return r.updateOllamaCloudUsageGroup(ctx, account, payload, true)
  128. }
  129. func ollamaCloudUsageManagedPayload(account *service.Account) map[string]any {
  130. payload := make(map[string]any, 3)
  131. if account == nil || account.Extra == nil {
  132. return payload
  133. }
  134. for _, key := range []string{
  135. service.OllamaCloudUsageSessionExtraKey,
  136. service.OllamaCloudUsageAutoRefreshExtraKey,
  137. service.OllamaCloudUsageSnapshotExtraKey,
  138. } {
  139. if value, ok := account.Extra[key]; ok {
  140. payload[key] = value
  141. }
  142. }
  143. return payload
  144. }
  145. func ollamaCloudUsageAccountHasSession(account *service.Account) bool {
  146. if account == nil || account.Extra == nil {
  147. return false
  148. }
  149. value, ok := account.Extra[service.OllamaCloudUsageSessionExtraKey].(string)
  150. return ok && value != ""
  151. }
  152. type lockedOllamaCloudUsageMember struct {
  153. id int64
  154. anchorMatches bool
  155. sessionJSON string
  156. autoJSON string
  157. snapshotJSON string
  158. }
  159. func (r *accountRepository) updateOllamaCloudUsageGroup(
  160. ctx context.Context,
  161. account *service.Account,
  162. payload map[string]any,
  163. requireExpectedState bool,
  164. ) error {
  165. if account == nil {
  166. return service.ErrAccountNilInput
  167. }
  168. if r == nil || r.client == nil || !service.IsOllamaCloudUsageAccount(account) {
  169. return service.ErrOllamaCloudUsageUnavailable
  170. }
  171. apiKey, ok := account.Credentials["api_key"].(string)
  172. if !ok || apiKey == "" {
  173. return service.ErrOllamaCloudUsageAccountInvalid
  174. }
  175. apply := func(txCtx context.Context, client *dbent.Client) error {
  176. matchesProxy, err := lockAndMatchProbeProxyIdentity(txCtx, client, account)
  177. if err != nil {
  178. return err
  179. }
  180. if !matchesProxy {
  181. return service.ErrOllamaCloudUsageIdentityChanged
  182. }
  183. members, err := lockOllamaCloudUsageGroup(txCtx, client, account, apiKey)
  184. if err != nil {
  185. return err
  186. }
  187. anchorMatches := false
  188. for _, member := range members {
  189. anchorMatches = anchorMatches || member.anchorMatches
  190. }
  191. if !anchorMatches {
  192. return service.ErrOllamaCloudUsageIdentityChanged
  193. }
  194. if requireExpectedState {
  195. expectedSession, err := canonicalAccountExtraJSON(account, service.OllamaCloudUsageSessionExtraKey)
  196. if err != nil {
  197. return err
  198. }
  199. expectedAuto, err := canonicalAccountExtraJSON(account, service.OllamaCloudUsageAutoRefreshExtraKey)
  200. if err != nil {
  201. return err
  202. }
  203. expectedSnapshot, err := canonicalAccountExtraJSON(account, service.OllamaCloudUsageSnapshotExtraKey)
  204. if err != nil {
  205. return err
  206. }
  207. stateMatches := false
  208. for _, member := range members {
  209. if canonicalJSON(member.sessionJSON) == expectedSession &&
  210. canonicalJSON(member.autoJSON) == expectedAuto &&
  211. canonicalJSON(member.snapshotJSON) == expectedSnapshot {
  212. stateMatches = true
  213. break
  214. }
  215. }
  216. if !stateMatches {
  217. return service.ErrOllamaCloudUsageIdentityChanged
  218. }
  219. }
  220. encoded, err := json.Marshal(payload)
  221. if err != nil {
  222. return err
  223. }
  224. memberIDs := make([]int64, len(members))
  225. for index := range members {
  226. memberIDs[index] = members[index].id
  227. }
  228. result, err := client.ExecContext(txCtx, `
  229. UPDATE accounts
  230. SET extra = (COALESCE(extra, '{}'::jsonb)
  231. - 'ollama_cloud_usage_session'
  232. - 'ollama_cloud_usage_auto_refresh'
  233. - 'ollama_cloud_usage_snapshot') || $1::jsonb,
  234. updated_at = NOW()
  235. WHERE deleted_at IS NULL
  236. AND `+ollamaCloudUsageEligibleSQL+`
  237. AND credentials ->> 'api_key' = $2
  238. AND id = ANY($3)
  239. `, string(encoded), apiKey, pq.Array(memberIDs))
  240. if err != nil {
  241. return err
  242. }
  243. affected, err := result.RowsAffected()
  244. if err != nil {
  245. return err
  246. }
  247. if affected != int64(len(members)) {
  248. return service.ErrOllamaCloudUsageIdentityChanged
  249. }
  250. return nil
  251. }
  252. if dbent.TxFromContext(ctx) != nil {
  253. return apply(ctx, clientFromContext(ctx, r.client))
  254. }
  255. tx, err := r.client.Tx(ctx)
  256. if errors.Is(err, dbent.ErrTxStarted) {
  257. return apply(ctx, r.client)
  258. }
  259. if err != nil {
  260. return err
  261. }
  262. defer func() { _ = tx.Rollback() }()
  263. txCtx := dbent.NewTxContext(ctx, tx)
  264. if err := apply(txCtx, tx.Client()); err != nil {
  265. return err
  266. }
  267. return tx.Commit()
  268. }
  269. func lockOllamaCloudUsageGroup(
  270. ctx context.Context,
  271. client *dbent.Client,
  272. account *service.Account,
  273. apiKey string,
  274. ) ([]lockedOllamaCloudUsageMember, error) {
  275. credentials, err := json.Marshal(normalizeJSONMap(account.Credentials))
  276. if err != nil {
  277. return nil, err
  278. }
  279. var proxyID any
  280. if account.ProxyID != nil {
  281. proxyID = *account.ProxyID
  282. }
  283. rows, err := client.QueryContext(ctx, `
  284. SELECT
  285. id,
  286. id = $2
  287. AND platform = $3
  288. AND type = $4
  289. AND credentials = $5::jsonb
  290. AND proxy_id IS NOT DISTINCT FROM $6,
  291. COALESCE((extra -> 'ollama_cloud_usage_session')::text, 'null'),
  292. COALESCE((extra -> 'ollama_cloud_usage_auto_refresh')::text, 'null'),
  293. COALESCE((extra -> 'ollama_cloud_usage_snapshot')::text, 'null')
  294. FROM accounts
  295. WHERE deleted_at IS NULL
  296. AND `+ollamaCloudUsageEligibleSQL+`
  297. AND credentials ->> 'api_key' = $1
  298. ORDER BY id
  299. FOR NO KEY UPDATE
  300. `, apiKey, account.ID, account.Platform, account.Type, string(credentials), proxyID)
  301. if err != nil {
  302. return nil, err
  303. }
  304. defer func() { _ = rows.Close() }()
  305. members := make([]lockedOllamaCloudUsageMember, 0, 1)
  306. for rows.Next() {
  307. var member lockedOllamaCloudUsageMember
  308. if err := rows.Scan(&member.id, &member.anchorMatches, &member.sessionJSON, &member.autoJSON, &member.snapshotJSON); err != nil {
  309. return nil, err
  310. }
  311. members = append(members, member)
  312. }
  313. if err := rows.Err(); err != nil {
  314. return nil, err
  315. }
  316. if len(members) == 0 {
  317. return nil, service.ErrOllamaCloudUsageIdentityChanged
  318. }
  319. return members, nil
  320. }
  321. func canonicalAccountExtraJSON(account *service.Account, key string) (string, error) {
  322. var value any
  323. if account != nil && account.Extra != nil {
  324. value = account.Extra[key]
  325. }
  326. raw, err := json.Marshal(value)
  327. if err != nil {
  328. return "", err
  329. }
  330. return canonicalJSON(string(raw)), nil
  331. }
  332. func canonicalJSON(raw string) string {
  333. var value any
  334. if err := json.Unmarshal([]byte(raw), &value); err != nil {
  335. return ""
  336. }
  337. encoded, err := json.Marshal(value)
  338. if err != nil {
  339. return ""
  340. }
  341. return string(encoded)
  342. }
  343. // ollamaCloudUsageParseRFC3339SQL reuses the verified RFC3339(/Nano) parse path
  344. // for a snapshot timestamp expression. Invalid or missing values fail open to NULL.
  345. //
  346. // The value is rewritten twice before it reaches jsonpath:
  347. // 1. Sub-second precision beyond 6 digits is truncated, because .datetime()
  348. // rejects more than microsecond resolution while Go emits 9 digits.
  349. // 2. A trailing "Z" is rewritten to "+00:00". jsonpath .datetime() only learned
  350. // to accept the ISO-8601 "Z" designator in PostgreSQL 17, and every timestamp
  351. // this service writes is UTC (hence "Z"). Without this rewrite the parse
  352. // silently yields NULL on PostgreSQL <= 16, which makes every due column NULL
  353. // and collapses ListDueOllamaCloudUsageAccounts into its fail-open branch.
  354. //
  355. // jsonpath (rather than a direct ::timestamptz cast) is required so that values
  356. // passing the shape regex but naming an impossible date (e.g. 2026-02-30) fail
  357. // open to NULL instead of aborting the whole query.
  358. func ollamaCloudUsageParseRFC3339SQL(expression string) string {
  359. return `CASE
  360. WHEN ` + expression + ` IS NULL THEN NULL
  361. WHEN ` + expression + ` ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$'
  362. THEN jsonb_path_query_first_tz(
  363. to_jsonb(regexp_replace(
  364. regexp_replace(
  365. ` + expression + `,
  366. '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$',
  367. '\1\2'
  368. ),
  369. 'Z$',
  370. '+00:00'
  371. )),
  372. '$.datetime()', '{}'::jsonb, true
  373. ) #>> '{}'
  374. ELSE NULL
  375. END`
  376. }
  377. // ListDueOllamaCloudUsageAccounts returns at most one truly-due activity-driven
  378. // candidate per exact API key. Due timing (debounce, max-wait, failure backoff)
  379. // is evaluated in SQL before LIMIT so non-due active groups cannot starve due ones.
  380. // Account.LastUsedAt is stamped with the group MAX(last_used_at) for a service
  381. // pure-function recheck against races between list and refresh.
  382. //
  383. // Rules mirror service.ollamaCloudUsageAutoRefreshDueAt (keep both in sync):
  384. // - missing/invalid snapshot or times → fail-open first due
  385. // - success: activity after fetched_at;
  386. // due_at = GREATEST(LEAST(last_used+debounce, fetched+maxWait), fetched+minFetchInterval)
  387. // - failed/unauthorized: activity after last_attempt; activity_due = LEAST(...);
  388. // final due_at is not earlier than a valid next_refresh_at (invalid/missing fail-open)
  389. func (r *accountRepository) ListDueOllamaCloudUsageAccounts(
  390. ctx context.Context,
  391. now time.Time,
  392. debounce, maxWait time.Duration,
  393. limit int,
  394. ) ([]service.Account, error) {
  395. if limit <= 0 {
  396. return []service.Account{}, nil
  397. }
  398. if r == nil || r.sql == nil {
  399. return nil, errors.New("account repository SQL executor not configured")
  400. }
  401. if debounce <= 0 {
  402. debounce = time.Minute
  403. }
  404. if maxWait <= 0 {
  405. maxWait = time.Hour
  406. }
  407. debounceSeconds := debounce.Seconds()
  408. maxWaitSeconds := maxWait.Seconds()
  409. minFetchIntervalSeconds := service.OllamaCloudUsageMinFetchInterval.Seconds()
  410. rows, err := r.sql.QueryContext(ctx, `
  411. WITH eligible AS (
  412. SELECT id,
  413. credentials ->> 'api_key' AS api_key,
  414. last_used_at,
  415. extra -> 'ollama_cloud_usage_snapshot' AS snapshot
  416. FROM accounts
  417. WHERE deleted_at IS NULL
  418. AND status = 'active'
  419. AND `+ollamaCloudUsageEligibleSQL+`
  420. AND jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'
  421. AND extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb
  422. ), group_activity AS (
  423. SELECT credentials ->> 'api_key' AS api_key,
  424. MAX(last_used_at) AS group_last_used_at
  425. FROM accounts
  426. WHERE deleted_at IS NULL
  427. AND `+ollamaCloudUsageEligibleSQL+`
  428. AND jsonb_typeof(credentials -> 'api_key') = 'string'
  429. GROUP BY credentials ->> 'api_key'
  430. ), joined AS (
  431. SELECT e.id, e.api_key, e.snapshot, g.group_last_used_at,
  432. e.snapshot #>> '{status}' AS status,
  433. e.snapshot #>> '{fetched_at}' AS fetched_at,
  434. e.snapshot #>> '{last_attempt_at}' AS last_attempt_at,
  435. e.snapshot #>> '{next_refresh_at}' AS next_refresh_at
  436. FROM eligible e
  437. JOIN group_activity g ON g.api_key = e.api_key
  438. ), parsed AS MATERIALIZED (
  439. SELECT id, api_key, snapshot, group_last_used_at, status,
  440. `+ollamaCloudUsageParseRFC3339SQL("fetched_at")+` AS parsed_fetched_at,
  441. `+ollamaCloudUsageParseRFC3339SQL("last_attempt_at")+` AS parsed_last_attempt_at,
  442. `+ollamaCloudUsageParseRFC3339SQL("next_refresh_at")+` AS parsed_next_refresh_at
  443. FROM joined
  444. ), timed AS (
  445. SELECT *,
  446. CASE
  447. WHEN status = 'ok'
  448. AND parsed_fetched_at IS NOT NULL
  449. AND group_last_used_at IS NOT NULL
  450. AND group_last_used_at > parsed_fetched_at::timestamptz
  451. THEN GREATEST(
  452. LEAST(
  453. group_last_used_at + make_interval(secs => $2::double precision),
  454. parsed_fetched_at::timestamptz + make_interval(secs => $3::double precision)
  455. ),
  456. parsed_fetched_at::timestamptz + make_interval(secs => $5::double precision)
  457. )
  458. WHEN status IN ('failed', 'unauthorized')
  459. AND parsed_last_attempt_at IS NOT NULL
  460. AND group_last_used_at IS NOT NULL
  461. AND group_last_used_at > parsed_last_attempt_at::timestamptz
  462. THEN GREATEST(
  463. LEAST(
  464. group_last_used_at + make_interval(secs => $2::double precision),
  465. parsed_last_attempt_at::timestamptz + make_interval(secs => $3::double precision)
  466. ),
  467. COALESCE(parsed_next_refresh_at::timestamptz, '-infinity'::timestamptz)
  468. )
  469. ELSE NULL
  470. END AS activity_due_at
  471. FROM parsed
  472. ), candidates AS (
  473. SELECT *,
  474. CASE
  475. WHEN snapshot IS NULL OR snapshot = 'null'::jsonb OR status IS NULL
  476. OR status NOT IN ('ok', 'failed', 'unauthorized') THEN 0
  477. WHEN status = 'ok' AND parsed_fetched_at IS NULL THEN 0
  478. WHEN status IN ('failed', 'unauthorized') AND parsed_last_attempt_at IS NULL THEN 0
  479. WHEN activity_due_at IS NOT NULL AND $1 >= activity_due_at THEN 1
  480. ELSE NULL
  481. END AS due_class,
  482. activity_due_at AS due_at
  483. FROM timed
  484. ), ranked AS (
  485. SELECT id, api_key, group_last_used_at, due_class, due_at,
  486. row_number() OVER (
  487. PARTITION BY api_key
  488. ORDER BY due_class,
  489. due_at NULLS FIRST,
  490. id
  491. ) AS group_rank
  492. FROM candidates
  493. WHERE due_class IS NOT NULL
  494. )
  495. SELECT id, group_last_used_at
  496. FROM ranked
  497. WHERE group_rank = 1
  498. ORDER BY due_class, due_at NULLS FIRST, id
  499. LIMIT $4
  500. `, now.UTC(), debounceSeconds, maxWaitSeconds, limit, minFetchIntervalSeconds)
  501. if err != nil {
  502. return nil, err
  503. }
  504. defer func() { _ = rows.Close() }()
  505. type dueRow struct {
  506. id int64
  507. groupLastUsed *time.Time
  508. }
  509. rowsOut := make([]dueRow, 0, limit)
  510. ids := make([]int64, 0, limit)
  511. for rows.Next() {
  512. var row dueRow
  513. if err := rows.Scan(&row.id, &row.groupLastUsed); err != nil {
  514. return nil, err
  515. }
  516. rowsOut = append(rowsOut, row)
  517. ids = append(ids, row.id)
  518. }
  519. if err := rows.Err(); err != nil {
  520. return nil, err
  521. }
  522. accounts, err := r.GetByIDs(ctx, ids)
  523. if err != nil {
  524. return nil, err
  525. }
  526. byID := make(map[int64]*service.Account, len(accounts))
  527. for _, account := range accounts {
  528. if account != nil {
  529. byID[account.ID] = account
  530. }
  531. }
  532. result := make([]service.Account, 0, len(rowsOut))
  533. for _, row := range rowsOut {
  534. account := byID[row.id]
  535. if account == nil {
  536. continue
  537. }
  538. // Stamp group MAX(last_used_at) for service due evaluation.
  539. if row.groupLastUsed != nil {
  540. ts := row.groupLastUsed.UTC()
  541. account.LastUsedAt = &ts
  542. } else {
  543. account.LastUsedAt = nil
  544. }
  545. result = append(result, *account)
  546. }
  547. return result, nil
  548. }