Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
564 lines
18 KiB
Go
564 lines
18 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
const (
|
|
ollamaCloudBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://([wW][wW][wW]\.)?[oO][lL][lL][aA][mM][aA]\.[cC][oO][mM](:443)?(/v1)?$`
|
|
ollamaCloudBaseURLMatchSQLPrefix = "btrim("
|
|
ollamaCloudBaseURLMatchSQLSuffix = ") ~ '" + ollamaCloudBaseURLRegexSQL + "'"
|
|
ollamaCloudUsageEligibleSQL = `
|
|
platform IN ('openai', 'anthropic')
|
|
AND type = 'apikey'
|
|
AND ` + ollamaCloudBaseURLMatchSQLPrefix + `credentials ->> 'base_url'` + ollamaCloudBaseURLMatchSQLSuffix + `
|
|
AND jsonb_typeof(credentials -> 'api_key') = 'string'
|
|
`
|
|
)
|
|
|
|
func ollamaCloudBaseURLMatchesSQL(expression string) string {
|
|
return ollamaCloudBaseURLMatchSQLPrefix + expression + ollamaCloudBaseURLMatchSQLSuffix
|
|
}
|
|
|
|
// ListOllamaCloudUsageGroupAccounts resolves every sibling for all supplied
|
|
// identities with one ID query and one batch hydration. API keys are query
|
|
// parameters only; no derived shared key is persisted.
|
|
func (r *accountRepository) ListOllamaCloudUsageGroupAccounts(ctx context.Context, accounts []*service.Account) ([]service.Account, error) {
|
|
if r == nil || r.sql == nil {
|
|
return nil, service.ErrOllamaCloudUsageUnavailable
|
|
}
|
|
keys := make([]string, 0, len(accounts))
|
|
seen := make(map[string]struct{}, len(accounts))
|
|
for _, account := range accounts {
|
|
if !service.IsOllamaCloudUsageAccount(account) || account.Credentials == nil {
|
|
continue
|
|
}
|
|
apiKey, ok := account.Credentials["api_key"].(string)
|
|
if !ok || apiKey == "" {
|
|
continue
|
|
}
|
|
if _, duplicate := seen[apiKey]; duplicate {
|
|
continue
|
|
}
|
|
seen[apiKey] = struct{}{}
|
|
keys = append(keys, apiKey)
|
|
}
|
|
if len(keys) == 0 {
|
|
return []service.Account{}, nil
|
|
}
|
|
rows, err := r.sql.QueryContext(ctx, `
|
|
SELECT id
|
|
FROM accounts
|
|
WHERE deleted_at IS NULL
|
|
AND `+ollamaCloudUsageEligibleSQL+`
|
|
AND credentials ->> 'api_key' = ANY($1)
|
|
ORDER BY id
|
|
`, pq.Array(keys))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
ids := make([]int64, 0, len(keys))
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
hydrated, err := r.GetByIDs(ctx, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]service.Account, 0, len(hydrated))
|
|
for _, account := range hydrated {
|
|
if account != nil {
|
|
result = append(result, *account)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (r *accountRepository) SaveOllamaCloudUsageSession(ctx context.Context, account *service.Account, ciphertext string, autoRefresh bool) error {
|
|
return r.updateOllamaCloudUsageGroup(ctx, account, map[string]any{
|
|
service.OllamaCloudUsageSessionExtraKey: ciphertext,
|
|
service.OllamaCloudUsageAutoRefreshExtraKey: autoRefresh,
|
|
}, false)
|
|
}
|
|
|
|
func (r *accountRepository) DeleteOllamaCloudUsageSession(ctx context.Context, account *service.Account) error {
|
|
return r.updateOllamaCloudUsageGroup(ctx, account, map[string]any{}, false)
|
|
}
|
|
|
|
func (r *accountRepository) SetOllamaCloudUsageAutoRefresh(ctx context.Context, account *service.Account, enabled bool) error {
|
|
if !ollamaCloudUsageAccountHasSession(account) {
|
|
return service.ErrOllamaCloudUsageSessionRequired
|
|
}
|
|
payload := ollamaCloudUsageManagedPayload(account)
|
|
payload[service.OllamaCloudUsageAutoRefreshExtraKey] = enabled
|
|
return r.updateOllamaCloudUsageGroup(ctx, account, payload, true)
|
|
}
|
|
|
|
func (r *accountRepository) UpdateOllamaCloudUsageSnapshot(ctx context.Context, account *service.Account, snapshot *service.OllamaCloudUsageSnapshot) error {
|
|
if account == nil || snapshot == nil {
|
|
return service.ErrAccountNilInput
|
|
}
|
|
if !ollamaCloudUsageAccountHasSession(account) {
|
|
return service.ErrOllamaCloudUsageSessionRequired
|
|
}
|
|
payload := ollamaCloudUsageManagedPayload(account)
|
|
payload[service.OllamaCloudUsageSnapshotExtraKey] = snapshot
|
|
return r.updateOllamaCloudUsageGroup(ctx, account, payload, true)
|
|
}
|
|
|
|
// DisableOllamaCloudUsageAutoRefresh is group-scoped and retains the loaded
|
|
// identity CAS. It cannot disable a new group after the account changes key.
|
|
func (r *accountRepository) DisableOllamaCloudUsageAutoRefresh(ctx context.Context, account *service.Account) error {
|
|
if !ollamaCloudUsageAccountHasSession(account) {
|
|
return service.ErrOllamaCloudUsageSessionRequired
|
|
}
|
|
payload := ollamaCloudUsageManagedPayload(account)
|
|
payload[service.OllamaCloudUsageAutoRefreshExtraKey] = false
|
|
delete(payload, service.OllamaCloudUsageSnapshotExtraKey)
|
|
return r.updateOllamaCloudUsageGroup(ctx, account, payload, true)
|
|
}
|
|
|
|
func ollamaCloudUsageManagedPayload(account *service.Account) map[string]any {
|
|
payload := make(map[string]any, 3)
|
|
if account == nil || account.Extra == nil {
|
|
return payload
|
|
}
|
|
for _, key := range []string{
|
|
service.OllamaCloudUsageSessionExtraKey,
|
|
service.OllamaCloudUsageAutoRefreshExtraKey,
|
|
service.OllamaCloudUsageSnapshotExtraKey,
|
|
} {
|
|
if value, ok := account.Extra[key]; ok {
|
|
payload[key] = value
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func ollamaCloudUsageAccountHasSession(account *service.Account) bool {
|
|
if account == nil || account.Extra == nil {
|
|
return false
|
|
}
|
|
value, ok := account.Extra[service.OllamaCloudUsageSessionExtraKey].(string)
|
|
return ok && value != ""
|
|
}
|
|
|
|
type lockedOllamaCloudUsageMember struct {
|
|
id int64
|
|
anchorMatches bool
|
|
sessionJSON string
|
|
autoJSON string
|
|
snapshotJSON string
|
|
}
|
|
|
|
func (r *accountRepository) updateOllamaCloudUsageGroup(
|
|
ctx context.Context,
|
|
account *service.Account,
|
|
payload map[string]any,
|
|
requireExpectedState bool,
|
|
) error {
|
|
if account == nil {
|
|
return service.ErrAccountNilInput
|
|
}
|
|
if r == nil || r.client == nil || !service.IsOllamaCloudUsageAccount(account) {
|
|
return service.ErrOllamaCloudUsageUnavailable
|
|
}
|
|
apiKey, ok := account.Credentials["api_key"].(string)
|
|
if !ok || apiKey == "" {
|
|
return service.ErrOllamaCloudUsageAccountInvalid
|
|
}
|
|
apply := func(txCtx context.Context, client *dbent.Client) error {
|
|
matchesProxy, err := lockAndMatchProbeProxyIdentity(txCtx, client, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !matchesProxy {
|
|
return service.ErrOllamaCloudUsageIdentityChanged
|
|
}
|
|
members, err := lockOllamaCloudUsageGroup(txCtx, client, account, apiKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
anchorMatches := false
|
|
for _, member := range members {
|
|
anchorMatches = anchorMatches || member.anchorMatches
|
|
}
|
|
if !anchorMatches {
|
|
return service.ErrOllamaCloudUsageIdentityChanged
|
|
}
|
|
if requireExpectedState {
|
|
expectedSession, err := canonicalAccountExtraJSON(account, service.OllamaCloudUsageSessionExtraKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
expectedAuto, err := canonicalAccountExtraJSON(account, service.OllamaCloudUsageAutoRefreshExtraKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
expectedSnapshot, err := canonicalAccountExtraJSON(account, service.OllamaCloudUsageSnapshotExtraKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stateMatches := false
|
|
for _, member := range members {
|
|
if canonicalJSON(member.sessionJSON) == expectedSession &&
|
|
canonicalJSON(member.autoJSON) == expectedAuto &&
|
|
canonicalJSON(member.snapshotJSON) == expectedSnapshot {
|
|
stateMatches = true
|
|
break
|
|
}
|
|
}
|
|
if !stateMatches {
|
|
return service.ErrOllamaCloudUsageIdentityChanged
|
|
}
|
|
}
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
memberIDs := make([]int64, len(members))
|
|
for index := range members {
|
|
memberIDs[index] = members[index].id
|
|
}
|
|
result, err := client.ExecContext(txCtx, `
|
|
UPDATE accounts
|
|
SET extra = (COALESCE(extra, '{}'::jsonb)
|
|
- 'ollama_cloud_usage_session'
|
|
- 'ollama_cloud_usage_auto_refresh'
|
|
- 'ollama_cloud_usage_snapshot') || $1::jsonb,
|
|
updated_at = NOW()
|
|
WHERE deleted_at IS NULL
|
|
AND `+ollamaCloudUsageEligibleSQL+`
|
|
AND credentials ->> 'api_key' = $2
|
|
AND id = ANY($3)
|
|
`, string(encoded), apiKey, pq.Array(memberIDs))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected != int64(len(members)) {
|
|
return service.ErrOllamaCloudUsageIdentityChanged
|
|
}
|
|
return nil
|
|
}
|
|
if dbent.TxFromContext(ctx) != nil {
|
|
return apply(ctx, clientFromContext(ctx, r.client))
|
|
}
|
|
tx, err := r.client.Tx(ctx)
|
|
if errors.Is(err, dbent.ErrTxStarted) {
|
|
return apply(ctx, r.client)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
txCtx := dbent.NewTxContext(ctx, tx)
|
|
if err := apply(txCtx, tx.Client()); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func lockOllamaCloudUsageGroup(
|
|
ctx context.Context,
|
|
client *dbent.Client,
|
|
account *service.Account,
|
|
apiKey string,
|
|
) ([]lockedOllamaCloudUsageMember, error) {
|
|
credentials, err := json.Marshal(normalizeJSONMap(account.Credentials))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var proxyID any
|
|
if account.ProxyID != nil {
|
|
proxyID = *account.ProxyID
|
|
}
|
|
rows, err := client.QueryContext(ctx, `
|
|
SELECT
|
|
id,
|
|
id = $2
|
|
AND platform = $3
|
|
AND type = $4
|
|
AND credentials = $5::jsonb
|
|
AND proxy_id IS NOT DISTINCT FROM $6,
|
|
COALESCE((extra -> 'ollama_cloud_usage_session')::text, 'null'),
|
|
COALESCE((extra -> 'ollama_cloud_usage_auto_refresh')::text, 'null'),
|
|
COALESCE((extra -> 'ollama_cloud_usage_snapshot')::text, 'null')
|
|
FROM accounts
|
|
WHERE deleted_at IS NULL
|
|
AND `+ollamaCloudUsageEligibleSQL+`
|
|
AND credentials ->> 'api_key' = $1
|
|
ORDER BY id
|
|
FOR NO KEY UPDATE
|
|
`, apiKey, account.ID, account.Platform, account.Type, string(credentials), proxyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
members := make([]lockedOllamaCloudUsageMember, 0, 1)
|
|
for rows.Next() {
|
|
var member lockedOllamaCloudUsageMember
|
|
if err := rows.Scan(&member.id, &member.anchorMatches, &member.sessionJSON, &member.autoJSON, &member.snapshotJSON); err != nil {
|
|
return nil, err
|
|
}
|
|
members = append(members, member)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(members) == 0 {
|
|
return nil, service.ErrOllamaCloudUsageIdentityChanged
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
func canonicalAccountExtraJSON(account *service.Account, key string) (string, error) {
|
|
var value any
|
|
if account != nil && account.Extra != nil {
|
|
value = account.Extra[key]
|
|
}
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return canonicalJSON(string(raw)), nil
|
|
}
|
|
|
|
func canonicalJSON(raw string) string {
|
|
var value any
|
|
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
|
return ""
|
|
}
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(encoded)
|
|
}
|
|
|
|
// ollamaCloudUsageParseRFC3339SQL reuses the verified RFC3339(/Nano) parse path
|
|
// for a snapshot timestamp expression. Invalid or missing values fail open to NULL.
|
|
//
|
|
// The value is rewritten twice before it reaches jsonpath:
|
|
// 1. Sub-second precision beyond 6 digits is truncated, because .datetime()
|
|
// rejects more than microsecond resolution while Go emits 9 digits.
|
|
// 2. A trailing "Z" is rewritten to "+00:00". jsonpath .datetime() only learned
|
|
// to accept the ISO-8601 "Z" designator in PostgreSQL 17, and every timestamp
|
|
// this service writes is UTC (hence "Z"). Without this rewrite the parse
|
|
// silently yields NULL on PostgreSQL <= 16, which makes every due column NULL
|
|
// and collapses ListDueOllamaCloudUsageAccounts into its fail-open branch.
|
|
//
|
|
// jsonpath (rather than a direct ::timestamptz cast) is required so that values
|
|
// passing the shape regex but naming an impossible date (e.g. 2026-02-30) fail
|
|
// open to NULL instead of aborting the whole query.
|
|
func ollamaCloudUsageParseRFC3339SQL(expression string) string {
|
|
return `CASE
|
|
WHEN ` + expression + ` IS NULL THEN NULL
|
|
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})$'
|
|
THEN jsonb_path_query_first_tz(
|
|
to_jsonb(regexp_replace(
|
|
regexp_replace(
|
|
` + expression + `,
|
|
'(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$',
|
|
'\1\2'
|
|
),
|
|
'Z$',
|
|
'+00:00'
|
|
)),
|
|
'$.datetime()', '{}'::jsonb, true
|
|
) #>> '{}'
|
|
ELSE NULL
|
|
END`
|
|
}
|
|
|
|
// ListDueOllamaCloudUsageAccounts returns at most one truly-due activity-driven
|
|
// candidate per exact API key. Due timing (debounce, max-wait, failure backoff)
|
|
// is evaluated in SQL before LIMIT so non-due active groups cannot starve due ones.
|
|
// Account.LastUsedAt is stamped with the group MAX(last_used_at) for a service
|
|
// pure-function recheck against races between list and refresh.
|
|
//
|
|
// Rules mirror service.ollamaCloudUsageAutoRefreshDueAt (keep both in sync):
|
|
// - missing/invalid snapshot or times → fail-open first due
|
|
// - success: activity after fetched_at;
|
|
// due_at = GREATEST(LEAST(last_used+debounce, fetched+maxWait), fetched+minFetchInterval)
|
|
// - failed/unauthorized: activity after last_attempt; activity_due = LEAST(...);
|
|
// final due_at is not earlier than a valid next_refresh_at (invalid/missing fail-open)
|
|
func (r *accountRepository) ListDueOllamaCloudUsageAccounts(
|
|
ctx context.Context,
|
|
now time.Time,
|
|
debounce, maxWait time.Duration,
|
|
limit int,
|
|
) ([]service.Account, error) {
|
|
if limit <= 0 {
|
|
return []service.Account{}, nil
|
|
}
|
|
if r == nil || r.sql == nil {
|
|
return nil, errors.New("account repository SQL executor not configured")
|
|
}
|
|
if debounce <= 0 {
|
|
debounce = time.Minute
|
|
}
|
|
if maxWait <= 0 {
|
|
maxWait = time.Hour
|
|
}
|
|
debounceSeconds := debounce.Seconds()
|
|
maxWaitSeconds := maxWait.Seconds()
|
|
minFetchIntervalSeconds := service.OllamaCloudUsageMinFetchInterval.Seconds()
|
|
rows, err := r.sql.QueryContext(ctx, `
|
|
WITH eligible AS (
|
|
SELECT id,
|
|
credentials ->> 'api_key' AS api_key,
|
|
last_used_at,
|
|
extra -> 'ollama_cloud_usage_snapshot' AS snapshot
|
|
FROM accounts
|
|
WHERE deleted_at IS NULL
|
|
AND status = 'active'
|
|
AND `+ollamaCloudUsageEligibleSQL+`
|
|
AND jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'
|
|
AND extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb
|
|
), group_activity AS (
|
|
SELECT credentials ->> 'api_key' AS api_key,
|
|
MAX(last_used_at) AS group_last_used_at
|
|
FROM accounts
|
|
WHERE deleted_at IS NULL
|
|
AND `+ollamaCloudUsageEligibleSQL+`
|
|
AND jsonb_typeof(credentials -> 'api_key') = 'string'
|
|
GROUP BY credentials ->> 'api_key'
|
|
), joined AS (
|
|
SELECT e.id, e.api_key, e.snapshot, g.group_last_used_at,
|
|
e.snapshot #>> '{status}' AS status,
|
|
e.snapshot #>> '{fetched_at}' AS fetched_at,
|
|
e.snapshot #>> '{last_attempt_at}' AS last_attempt_at,
|
|
e.snapshot #>> '{next_refresh_at}' AS next_refresh_at
|
|
FROM eligible e
|
|
JOIN group_activity g ON g.api_key = e.api_key
|
|
), parsed AS MATERIALIZED (
|
|
SELECT id, api_key, snapshot, group_last_used_at, status,
|
|
`+ollamaCloudUsageParseRFC3339SQL("fetched_at")+` AS parsed_fetched_at,
|
|
`+ollamaCloudUsageParseRFC3339SQL("last_attempt_at")+` AS parsed_last_attempt_at,
|
|
`+ollamaCloudUsageParseRFC3339SQL("next_refresh_at")+` AS parsed_next_refresh_at
|
|
FROM joined
|
|
), timed AS (
|
|
SELECT *,
|
|
CASE
|
|
WHEN status = 'ok'
|
|
AND parsed_fetched_at IS NOT NULL
|
|
AND group_last_used_at IS NOT NULL
|
|
AND group_last_used_at > parsed_fetched_at::timestamptz
|
|
THEN GREATEST(
|
|
LEAST(
|
|
group_last_used_at + make_interval(secs => $2::double precision),
|
|
parsed_fetched_at::timestamptz + make_interval(secs => $3::double precision)
|
|
),
|
|
parsed_fetched_at::timestamptz + make_interval(secs => $5::double precision)
|
|
)
|
|
WHEN status IN ('failed', 'unauthorized')
|
|
AND parsed_last_attempt_at IS NOT NULL
|
|
AND group_last_used_at IS NOT NULL
|
|
AND group_last_used_at > parsed_last_attempt_at::timestamptz
|
|
THEN GREATEST(
|
|
LEAST(
|
|
group_last_used_at + make_interval(secs => $2::double precision),
|
|
parsed_last_attempt_at::timestamptz + make_interval(secs => $3::double precision)
|
|
),
|
|
COALESCE(parsed_next_refresh_at::timestamptz, '-infinity'::timestamptz)
|
|
)
|
|
ELSE NULL
|
|
END AS activity_due_at
|
|
FROM parsed
|
|
), candidates AS (
|
|
SELECT *,
|
|
CASE
|
|
WHEN snapshot IS NULL OR snapshot = 'null'::jsonb OR status IS NULL
|
|
OR status NOT IN ('ok', 'failed', 'unauthorized') THEN 0
|
|
WHEN status = 'ok' AND parsed_fetched_at IS NULL THEN 0
|
|
WHEN status IN ('failed', 'unauthorized') AND parsed_last_attempt_at IS NULL THEN 0
|
|
WHEN activity_due_at IS NOT NULL AND $1 >= activity_due_at THEN 1
|
|
ELSE NULL
|
|
END AS due_class,
|
|
activity_due_at AS due_at
|
|
FROM timed
|
|
), ranked AS (
|
|
SELECT id, api_key, group_last_used_at, due_class, due_at,
|
|
row_number() OVER (
|
|
PARTITION BY api_key
|
|
ORDER BY due_class,
|
|
due_at NULLS FIRST,
|
|
id
|
|
) AS group_rank
|
|
FROM candidates
|
|
WHERE due_class IS NOT NULL
|
|
)
|
|
SELECT id, group_last_used_at
|
|
FROM ranked
|
|
WHERE group_rank = 1
|
|
ORDER BY due_class, due_at NULLS FIRST, id
|
|
LIMIT $4
|
|
`, now.UTC(), debounceSeconds, maxWaitSeconds, limit, minFetchIntervalSeconds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
type dueRow struct {
|
|
id int64
|
|
groupLastUsed *time.Time
|
|
}
|
|
rowsOut := make([]dueRow, 0, limit)
|
|
ids := make([]int64, 0, limit)
|
|
for rows.Next() {
|
|
var row dueRow
|
|
if err := rows.Scan(&row.id, &row.groupLastUsed); err != nil {
|
|
return nil, err
|
|
}
|
|
rowsOut = append(rowsOut, row)
|
|
ids = append(ids, row.id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
accounts, err := r.GetByIDs(ctx, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
byID := make(map[int64]*service.Account, len(accounts))
|
|
for _, account := range accounts {
|
|
if account != nil {
|
|
byID[account.ID] = account
|
|
}
|
|
}
|
|
result := make([]service.Account, 0, len(rowsOut))
|
|
for _, row := range rowsOut {
|
|
account := byID[row.id]
|
|
if account == nil {
|
|
continue
|
|
}
|
|
// Stamp group MAX(last_used_at) for service due evaluation.
|
|
if row.groupLastUsed != nil {
|
|
ts := row.groupLastUsed.UTC()
|
|
account.LastUsedAt = &ts
|
|
} else {
|
|
account.LastUsedAt = nil
|
|
}
|
|
result = append(result, *account)
|
|
}
|
|
return result, nil
|
|
}
|