Files
sub2api/backend/internal/repository/account_repo_opencode_go_usage.go
T

517 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
// 与 service.isOpenCodeGoBaseURL 对齐:Go 侧接受显式默认端口 :443(parsed.Host ==
// hostname+":443")与两个官方基址变体(CC/Responses 的 /zen/go/v1 与 Anthropic
// 的 /zen/go),SQL 正则必须同样接受,否则同一 base_url 在 Go 判定 eligible 而
// SQL 判定不 eligible,身份清理与组查询会漏行。只做 parity,不扩域名/路径;
// Zen 基址(/zen、/zen/v1)必须拒绝。
opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI](:[4][4][3])?/[zZ][eE][nN]/[gG][oO](/[vV]1)?/?$`
opencodeGoBaseURLMatchSQLPrefix = "btrim("
opencodeGoBaseURLMatchSQLSuffix = ") ~ '" + opencodeGoBaseURLRegexSQL + "'"
// opencodeGoUsageMountPlatformsSQL 是 service.isOpenCodeGoUsageMountPlatform 的
// SQL 镜像:OpenCode Go key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台
// 下复用,与 ollama 的 ollamaCloudUsagePlatformsSQL 完全一致。opencode_go
// 平台本身不在名单内——平台账号走下方 account_mode 分支。所有平台白名单 SQL
// 只允许引用本常量,不得各处重写字面量,防止漂移。
opencodeGoUsageMountPlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'"
// opencodeGoUsageEligibleSQL 与 service.IsOpenCodeGoUsageAccount 互为镜像:
// - opencode_go 平台:account_mode 存储于 credentials(domain/constants.go),
// 未设置/为 null/非 "zen" 一律视为 Go 订阅,与 GetOpenCodeAccountMode 的
// 默认兼容逻辑一致;COALESCE 把 <> 对 NULL 的结果归一为 true。不校验
// base_url(平台字段已是权威来源)。
// - 挂载白名单平台:base_url 必须匹配官方 OpenCode Go 基址正则。
//
// 已知且可接受的差异:btrim 只去空格,Go 侧 strings.TrimSpace 还会去
// \t\n\v\f\r 等空白;account_mode 等凭证字段实际不会出现这类空白,且改用
// btrim(x, E' \t\n\r\f\v') 会在 parity 关键的 SQL 字符串里引入转义脆弱性,
// 故保持现状不改行为。
opencodeGoUsageEligibleSQL = `
(
(platform = 'opencode_go'
AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true))
OR (platform IN (` + opencodeGoUsageMountPlatformsSQL + `)
AND ` + opencodeGoBaseURLMatchSQLPrefix + `credentials ->> 'base_url'` + opencodeGoBaseURLMatchSQLSuffix + `)
)
AND type = 'apikey'
AND jsonb_typeof(credentials -> 'api_key') = 'string'
`
)
// ListOpenCodeGoUsageGroupAccounts 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) ListOpenCodeGoUsageGroupAccounts(ctx context.Context, accounts []*service.Account) ([]service.Account, error) {
if r == nil || r.sql == nil {
return nil, service.ErrOpenCodeGoUsageUnavailable
}
keys := make([]string, 0, len(accounts))
seen := make(map[string]struct{}, len(accounts))
for _, account := range accounts {
if !service.IsOpenCodeGoUsageAccount(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 `+opencodeGoUsageEligibleSQL+`
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
}
// SetOpenCodeGoUsageAutoRefresh persists the group-scoped auto-refresh switch.
func (r *accountRepository) SetOpenCodeGoUsageAutoRefresh(ctx context.Context, account *service.Account, enabled bool) error {
if account == nil {
return service.ErrAccountNilInput
}
if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) {
return service.ErrOpenCodeGoUsageUnavailable
}
return r.updateOpenCodeGoUsageGroup(ctx, account, map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: enabled,
}, nil, true)
}
// UpdateOpenCodeGoUsageSnapshot persists the group-scoped usage snapshot.
func (r *accountRepository) UpdateOpenCodeGoUsageSnapshot(ctx context.Context, account *service.Account, snapshot *service.OpenCodeGoUsageSnapshot) error {
if account == nil || snapshot == nil {
return service.ErrAccountNilInput
}
if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) {
return service.ErrOpenCodeGoUsageUnavailable
}
return r.updateOpenCodeGoUsageGroup(ctx, account, map[string]any{
service.OpenCodeGoUsageSnapshotExtraKey: snapshot,
}, nil, true)
}
// DisableOpenCodeGoUsageAutoRefresh is group-scoped and retains the loaded
// identity CAS. It cannot disable a new group after the account changes key.
func (r *accountRepository) DisableOpenCodeGoUsageAutoRefresh(ctx context.Context, account *service.Account) error {
if account == nil {
return service.ErrAccountNilInput
}
if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) {
return service.ErrOpenCodeGoUsageUnavailable
}
payload := openCodeGoUsageManagedPayload(account)
payload[service.OpenCodeGoUsageAutoRefreshExtraKey] = false
delete(payload, service.OpenCodeGoUsageSnapshotExtraKey)
return r.updateOpenCodeGoUsageGroup(ctx, account, payload,
[]string{service.OpenCodeGoUsageSnapshotExtraKey}, true)
}
func openCodeGoUsageManagedPayload(account *service.Account) map[string]any {
payload := make(map[string]any, 2)
if account == nil || account.Extra == nil {
return payload
}
for _, key := range []string{
service.OpenCodeGoUsageAutoRefreshExtraKey,
service.OpenCodeGoUsageSnapshotExtraKey,
} {
if value, ok := account.Extra[key]; ok {
payload[key] = value
}
}
return payload
}
type lockedOpenCodeGoUsageMember struct {
id int64
anchorMatches bool
autoJSON string
snapshotJSON string
}
// updateOpenCodeGoUsageGroup locks every member of the exact api_key group and
// merges the payload onto each member's extra. The merge is pure
// (COALESCE(extra,'{}') || payload) unless deleteKeys is non-empty, in which
// case those keys are removed first — used only by the group-level disable path
// to clear the stale snapshot. Managed keys are never removed on ordinary
// writes, so a snapshot write can never wipe the auto-refresh switch or vice versa.
func (r *accountRepository) updateOpenCodeGoUsageGroup(
ctx context.Context,
account *service.Account,
payload map[string]any,
deleteKeys []string,
requireExpectedState bool,
) error {
if account == nil {
return service.ErrAccountNilInput
}
if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) {
return service.ErrOpenCodeGoUsageUnavailable
}
apiKey, ok := account.Credentials["api_key"].(string)
if !ok || apiKey == "" {
return service.ErrOpenCodeGoUsageAccountInvalid
}
apply := func(txCtx context.Context, client *dbent.Client) error {
matchesProxy, err := lockAndMatchProbeProxyIdentity(txCtx, client, account)
if err != nil {
return err
}
if !matchesProxy {
return service.ErrOpenCodeGoUsageIdentityChanged
}
members, err := lockOpenCodeGoUsageGroup(txCtx, client, account, apiKey)
if err != nil {
return err
}
anchorMatches := false
for _, member := range members {
anchorMatches = anchorMatches || member.anchorMatches
}
if !anchorMatches {
return service.ErrOpenCodeGoUsageIdentityChanged
}
if requireExpectedState {
expectedAuto, err := canonicalAccountExtraJSON(account, service.OpenCodeGoUsageAutoRefreshExtraKey)
if err != nil {
return err
}
expectedSnapshot, err := canonicalAccountExtraJSON(account, service.OpenCodeGoUsageSnapshotExtraKey)
if err != nil {
return err
}
stateMatches := false
for _, member := range members {
if canonicalJSON(member.autoJSON) == expectedAuto &&
canonicalJSON(member.snapshotJSON) == expectedSnapshot {
stateMatches = true
break
}
}
if !stateMatches {
return service.ErrOpenCodeGoUsageIdentityChanged
}
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
memberIDs := make([]int64, len(members))
for index := range members {
memberIDs[index] = members[index].id
}
mergeExpr := "COALESCE(extra, '{}'::jsonb)"
for _, key := range deleteKeys {
mergeExpr += " - " + pq.QuoteLiteral(key)
}
mergeExpr += " || $1::jsonb"
result, err := client.ExecContext(txCtx, `
UPDATE accounts
SET extra = `+mergeExpr+`,
updated_at = NOW()
WHERE deleted_at IS NULL
AND `+opencodeGoUsageEligibleSQL+`
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.ErrOpenCodeGoUsageIdentityChanged
}
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 lockOpenCodeGoUsageGroup(
ctx context.Context,
client *dbent.Client,
account *service.Account,
apiKey string,
) ([]lockedOpenCodeGoUsageMember, 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 -> 'opencode_go_usage_auto_refresh')::text, 'null'),
COALESCE((extra -> 'opencode_go_usage_snapshot')::text, 'null')
FROM accounts
WHERE deleted_at IS NULL
AND `+opencodeGoUsageEligibleSQL+`
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([]lockedOpenCodeGoUsageMember, 0, 1)
for rows.Next() {
var member lockedOpenCodeGoUsageMember
if err := rows.Scan(&member.id, &member.anchorMatches, &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.ErrOpenCodeGoUsageIdentityChanged
}
return members, nil
}
// ListDueOpenCodeGoUsageAccounts 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.openCodeGoUsageAutoRefreshDueAt (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) ListDueOpenCodeGoUsageAccounts(
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 = 15 * time.Minute
}
debounceSeconds := debounce.Seconds()
maxWaitSeconds := maxWait.Seconds()
minFetchIntervalSeconds := service.OpenCodeGoUsageMinFetchInterval.Seconds()
rows, err := r.sql.QueryContext(ctx, `
WITH eligible AS (
SELECT id,
credentials ->> 'api_key' AS api_key,
last_used_at,
extra -> 'opencode_go_usage_snapshot' AS snapshot
FROM accounts
WHERE deleted_at IS NULL
AND status = 'active'
AND `+opencodeGoUsageEligibleSQL+`
AND extra @> '{"opencode_go_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 `+opencodeGoUsageEligibleSQL+`
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
}