Files
sub2api/backend/internal/repository/gateway_cache.go
T
Brisbanehuangandshaw 20ad5ec506 feat(scheduler): per-group profit control for token account admission
Group pricing (rate multiplier, peak windows, per-user overrides) and
account cost (accounts.rate_multiplier) already live side by side, but
nothing stops the scheduler from handing a request to an account whose
cost multiplier exceeds what the group's pricing can profitably serve.
Add an opt-in per-group profit gate that filters scheduling candidates
by a margin rule, while ordering, scoring, stickiness and breakers keep
working unchanged among qualified accounts.

Admission rule: an account qualifies iff U <= D * (1 - min_margin -
safety_buffer) within a small relative epsilon, where U is
accounts.rate_multiplier (0 is legal; missing/negative/NaN/Inf are
conservatively rejected as invalid) and D is the requester's effective
downstream multiplier (user-group override ?? group default, times the
group peak factor) frozen at the request's pricing instant.

- groups gain profit_control_enabled / profit_min_margin /
  profit_safety_buffer (migration 191); the durable auth-cache
  invalidation trigger additionally watches the profit and pricing
  columns (migration 192) so out-of-band group edits cannot leave
  stale auth snapshots; GetByKeyForAuth explicitly projects the new
  columns and the API-key auth snapshot version is bumped to force a
  refresh of pre-existing snapshots
- request-level pricing instant: token entry points install pricingAt
  into ctx; the profit threshold D and the RecordUsage peak factor
  read the same instant, so one request never changes price mid-flight
  across waits/retries/failover (media and unwired paths keep the
  existing record-time semantics)
- the gate covers token requests on openai, anthropic, gemini, grok
  and antigravity groups: OpenAI-family handlers via
  WithOpenAIRequestPricingContext (responses incl. WS bridge, chat
  completions, messages, embeddings, alpha search), the shared gateway
  via WithGatewayTokenRequestPricing (messages, chat completions,
  responses, gemini model actions); composite groups cannot enable it
  directly; image/video/models/usage/count_tokens stay ungated and an
  explicit image-generation intent suppresses the gate end to end
- post-slot recheck: after a slot is acquired the account is re-read
  via SchedulerSnapshotService.GetAccount (scheduler cache, then DB;
  only when both fail the check fails open with WARN + metric); a
  vetoed account releases its slot and joins the request's exclusion
  set for reselection; sticky bindings are written only after the
  final check passes, and an over-threshold sticky account is skipped,
  not deleted, so it comes back once its rate recovers
- sticky-session cache contract: GatewayCache.GetSessionAccountID now
  returns ErrStickySessionNotFound on a miss (mapped from redis.Nil in
  the repository implementation, mirroring ErrRefreshTokenNotFound) so
  the profit sticky path can distinguish "no binding yet" from a real
  read failure without importing the cache driver in service code
- cross-group re-entry (composite parent -> member group) resolves the
  gate against the member group and clears a stale parent gate instead
  of letting a foreign threshold veto accounts
- per-platform/group activity counters (installs, threshold vetoes,
  invalid-rate vetoes, refresh failures) for observability
- admin UI: profit-control section on the five platforms' group forms
  with percent input, validation and platform-switch reset; group
  create/update/duplicate normalize and validate the config at a
  single choke point
- cmd/profit-preview: offline what-if tool that replays the production
  admission semantics over an exported config/account/override/model
  dump, reports per-model admitted-account counts under the default
  and the worst-case (lowest user override) D, and surfaces probe-sync
  staleness as warnings without affecting admission

Tests: service unit coverage for gate resolution/veto/threshold
epsilon/pricing instant/suppress marker/scheduler filtering and
post-slot recheck (incl. -race on the profit surface), unit-tagged
handler slot-recheck and capability-mapping regressions, sqlmock and
real-PostgreSQL integration regressions for the GetByKeyForAuth
projection and the migration-192 trigger watch list, API contract
update, and frontend specs for the five-platform form helpers.
2026-08-01 22:39:31 +08:00

226 lines
7.8 KiB
Go

package repository
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const stickySessionPrefix = "sticky_session:"
const liveCallPrefix = "live:call:"
type gatewayCache struct {
rdb *redis.Client
}
func NewGatewayCache(rdb *redis.Client) service.GatewayCache {
return &gatewayCache{rdb: rdb}
}
// buildSessionKey 构建 session key,包含 groupID 实现分组隔离
// 格式: sticky_session:{groupID}:{sessionHash}
func buildSessionKey(groupID int64, sessionHash string) string {
return fmt.Sprintf("%s%d:%s", stickySessionPrefix, groupID, sessionHash)
}
func (c *gatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
key := buildSessionKey(groupID, sessionHash)
accountID, err := c.rdb.Get(ctx, key).Int64()
if err != nil {
if errors.Is(err, redis.Nil) {
return 0, service.ErrStickySessionNotFound
}
return 0, err
}
return accountID, nil
}
func (c *gatewayCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
key := buildSessionKey(groupID, sessionHash)
return c.rdb.Set(ctx, key, accountID, ttl).Err()
}
func (c *gatewayCache) RefreshSessionTTL(ctx context.Context, groupID int64, sessionHash string, ttl time.Duration) error {
key := buildSessionKey(groupID, sessionHash)
return c.rdb.Expire(ctx, key, ttl).Err()
}
// DeleteSessionAccountID 删除粘性会话与账号的绑定关系。
// 当检测到绑定的账号不可用(如状态错误、禁用、不可调度等)时调用,
// 以便下次请求能够重新选择可用账号。
//
// DeleteSessionAccountID removes the sticky session binding for the given session.
// Called when the bound account becomes unavailable (e.g., error status, disabled,
// or unschedulable), allowing subsequent requests to select a new available account.
func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error {
key := buildSessionKey(groupID, sessionHash)
return c.rdb.Del(ctx, key).Err()
}
// Compile-time assertion: gatewayCache must implement CyberSessionBlockStore.
var _ service.CyberSessionBlockStore = (*gatewayCache)(nil)
var _ service.LiveCallStore = (*gatewayCache)(nil)
const cyberSessionBlockPrefix = "cyber_session_block:"
// SetCyberSessionBlocked 把被 cyber_policy 命中的会话写入屏蔽表(TTL 自动过期)。
// 存储值 "1" 作为存在标记(IsCyberSessionBlocked 只检查 key 是否存在,不读值)。
func (c *gatewayCache) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error {
return c.rdb.Set(ctx, cyberSessionBlockPrefix+key, "1", ttl).Err()
}
// IsCyberSessionBlocked 查询会话是否在屏蔽表中。
func (c *gatewayCache) IsCyberSessionBlocked(ctx context.Context, key string) (bool, error) {
n, err := c.rdb.Exists(ctx, cyberSessionBlockPrefix+key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
var claimLiveControllerScript = redis.NewScript(`
local key = KEYS[1]
local target = ARGV[1]
local owner = ARGV[2]
local current = redis.call('HGET', key, 'controller')
if current == false or current == 'closed' then
return 0
end
if target == 'observer' and current ~= 'pending' then
return 0
end
if target == 'proxy' and current ~= 'pending' and current ~= 'observer' and
(current ~= 'proxy' or redis.call('HGET', key, 'controller_owner') ~= owner) then
return 0
end
redis.call('HSET', key, 'controller', target, 'controller_owner', owner)
return 1
`)
var markLiveCallClosedScript = redis.NewScript(`
local key = KEYS[1]
if redis.call('EXISTS', key) == 0 then
return 0
end
if redis.call('HGET', key, 'controller') == 'closed' then
return 0
end
redis.call('HSET', key, 'controller', 'closed', 'controller_owner', '')
redis.call('EXPIRE', key, ARGV[1])
return 1
`)
var releaseLiveControllerScript = redis.NewScript(`
local key = KEYS[1]
if redis.call('HGET', key, 'controller') ~= 'proxy' or
redis.call('HGET', key, 'controller_owner') ~= ARGV[1] then
return 0
end
redis.call('HSET', key, 'controller', 'pending', 'controller_owner', '')
return 1
`)
func liveCallKey(callHash string) string {
return liveCallPrefix + callHash
}
func HashLiveCallID(callID string) string {
sum := sha256.Sum256([]byte(callID))
return hex.EncodeToString(sum[:])
}
func (c *gatewayCache) SaveLiveCall(ctx context.Context, record *service.LiveCallRecord, ttl time.Duration) error {
if record == nil || record.CallHash == "" || record.CallID == "" {
return fmt.Errorf("invalid live call record")
}
values := map[string]any{
"call_id": record.CallID,
"account_id": record.AccountID,
"api_key_id": record.APIKeyID,
"user_id": record.UserID,
"group_id": record.GroupID,
"subscription_id": record.SubscriptionID,
"lease_id": record.LeaseID,
"model": record.Model,
"created_at": record.CreatedAt.UnixMilli(),
"expires_at": record.ExpiresAt.UnixMilli(),
"controller": record.Controller,
"controller_owner": record.ControllerOwner,
"user_agent": record.UserAgent,
"ip_address": record.IPAddress,
"inbound_endpoint": record.InboundEndpoint,
"attestation": record.AttestationCiphertext,
}
key := liveCallKey(record.CallHash)
pipe := c.rdb.TxPipeline()
pipe.HSet(ctx, key, values)
pipe.Expire(ctx, key, ttl)
_, err := pipe.Exec(ctx)
return err
}
func (c *gatewayCache) GetLiveCall(ctx context.Context, callHash string) (*service.LiveCallRecord, error) {
values, err := c.rdb.HGetAll(ctx, liveCallKey(callHash)).Result()
if err != nil {
return nil, err
}
if len(values) == 0 {
return nil, service.ErrLiveCallNotFound
}
parseInt := func(field string) int64 {
value, _ := strconv.ParseInt(values[field], 10, 64)
return value
}
createdAt := time.UnixMilli(parseInt("created_at"))
expiresAt := time.UnixMilli(parseInt("expires_at"))
return &service.LiveCallRecord{
CallID: values["call_id"],
CallHash: callHash,
AccountID: parseInt("account_id"),
APIKeyID: parseInt("api_key_id"),
UserID: parseInt("user_id"),
GroupID: parseInt("group_id"),
SubscriptionID: parseInt("subscription_id"),
LeaseID: values["lease_id"],
Model: values["model"],
CreatedAt: createdAt,
ExpiresAt: expiresAt,
Controller: values["controller"],
ControllerOwner: values["controller_owner"],
UserAgent: values["user_agent"],
IPAddress: values["ip_address"],
InboundEndpoint: values["inbound_endpoint"],
AttestationCiphertext: values["attestation"],
}, nil
}
func (c *gatewayCache) ClaimLiveController(ctx context.Context, callHash, controller, owner string) (bool, error) {
result, err := claimLiveControllerScript.Run(ctx, c.rdb, []string{liveCallKey(callHash)}, controller, owner).Int()
return result == 1, err
}
func (c *gatewayCache) GetLiveController(ctx context.Context, callHash string) (string, error) {
value, err := c.rdb.HGet(ctx, liveCallKey(callHash), "controller").Result()
if err == redis.Nil {
return "", service.ErrLiveCallNotFound
}
return value, err
}
func (c *gatewayCache) ReleaseLiveController(ctx context.Context, callHash, owner string) (bool, error) {
result, err := releaseLiveControllerScript.Run(ctx, c.rdb, []string{liveCallKey(callHash)}, owner).Int()
return result == 1, err
}
func (c *gatewayCache) MarkLiveCallClosed(ctx context.Context, callHash string, ttl time.Duration) (bool, error) {
result, err := markLiveCallClosedScript.Run(ctx, c.rdb, []string{liveCallKey(callHash)}, int64(ttl.Seconds())).Int()
return result == 1, err
}