| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- 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 + "'"
- // ollamaCloudUsagePlatformsSQL 是 service.isOllamaCloudUsagePlatform 的 SQL
- // 镜像:Ollama Cloud key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台
- // 下复用。所有平台白名单 SQL 只允许引用本常量,不得各处重写字面量,防止漂移。
- ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'"
- ollamaCloudUsageEligibleSQL = `
- platform IN (` + ollamaCloudUsagePlatformsSQL + `)
- 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
- }
|