490 lines
16 KiB
Go
490 lines
16 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
const stickySessionPrefix = "sticky_session:"
|
|
const openAIResponsesSessionWindowPrefix = "openai_responses_session_window:"
|
|
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 buildOpenAIResponsesSessionWindowKey(groupID int64, sessionHash string) string {
|
|
return fmt.Sprintf("%s%d:%s", openAIResponsesSessionWindowPrefix, 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()
|
|
}
|
|
|
|
var claimOpenAIResponsesSessionWindowScript = redis.NewScript(`
|
|
local previous = redis.call('GET', KEYS[1])
|
|
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
|
return previous
|
|
`)
|
|
|
|
var compareAndRefreshOpenAIResponsesSessionWindowScript = redis.NewScript(`
|
|
local current = redis.call('GET', KEYS[1])
|
|
if current == false or current ~= ARGV[1] then
|
|
return 0
|
|
end
|
|
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
return 1
|
|
`)
|
|
|
|
var compareAndDeleteOpenAIResponsesSessionWindowScript = redis.NewScript(`
|
|
local current = redis.call('GET', KEYS[1])
|
|
if current == false or current ~= ARGV[1] then
|
|
return 0
|
|
end
|
|
redis.call('DEL', KEYS[1])
|
|
return 1
|
|
`)
|
|
|
|
func (c *gatewayCache) ClaimOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, owner []byte, ttl time.Duration) ([]byte, error) {
|
|
if c == nil || c.rdb == nil {
|
|
return nil, errors.New("gateway cache unavailable")
|
|
}
|
|
if len(owner) == 0 || strings.TrimSpace(sessionHash) == "" || ttl <= 0 {
|
|
return nil, errors.New("invalid OpenAI Responses session-window claim")
|
|
}
|
|
result, err := claimOpenAIResponsesSessionWindowScript.Run(
|
|
ctx,
|
|
c.rdb,
|
|
[]string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)},
|
|
owner,
|
|
ttl.Milliseconds(),
|
|
).Result()
|
|
if errors.Is(err, redis.Nil) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch value := result.(type) {
|
|
case nil:
|
|
return nil, nil
|
|
case string:
|
|
return []byte(value), nil
|
|
case []byte:
|
|
return append([]byte(nil), value...), nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected OpenAI Responses session-window claim result %T", result)
|
|
}
|
|
}
|
|
|
|
func (c *gatewayCache) CompareAndRefreshOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte, ttl time.Duration) (bool, error) {
|
|
if c == nil || c.rdb == nil {
|
|
return false, errors.New("gateway cache unavailable")
|
|
}
|
|
if len(expected) == 0 || strings.TrimSpace(sessionHash) == "" || ttl <= 0 {
|
|
return false, errors.New("invalid OpenAI Responses session-window refresh")
|
|
}
|
|
n, err := compareAndRefreshOpenAIResponsesSessionWindowScript.Run(
|
|
ctx,
|
|
c.rdb,
|
|
[]string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)},
|
|
expected,
|
|
ttl.Milliseconds(),
|
|
).Int()
|
|
return n == 1, err
|
|
}
|
|
|
|
func (c *gatewayCache) CompareAndDeleteOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte) (bool, error) {
|
|
if c == nil || c.rdb == nil {
|
|
return false, errors.New("gateway cache unavailable")
|
|
}
|
|
if len(expected) == 0 || strings.TrimSpace(sessionHash) == "" {
|
|
return false, errors.New("invalid OpenAI Responses session-window delete")
|
|
}
|
|
n, err := compareAndDeleteOpenAIResponsesSessionWindowScript.Run(
|
|
ctx,
|
|
c.rdb,
|
|
[]string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)},
|
|
expected,
|
|
).Int()
|
|
return n == 1, err
|
|
}
|
|
|
|
var _ service.OpenAIWSSessionPreemptionCache = (*gatewayCache)(nil)
|
|
|
|
const (
|
|
grokVideoPendingBillingPrefix = "grok_video_pending:"
|
|
grokVideoBilledPrefix = "grok_video_billed:"
|
|
)
|
|
|
|
func (c *gatewayCache) SetGrokVideoPendingBilling(ctx context.Context, key string, payload []byte, ttl time.Duration) error {
|
|
if c == nil || c.rdb == nil {
|
|
return errors.New("gateway cache unavailable")
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
if key == "" || len(payload) == 0 {
|
|
return errors.New("invalid grok video pending billing payload")
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = 24 * time.Hour
|
|
}
|
|
return c.rdb.Set(ctx, grokVideoPendingBillingPrefix+key, payload, ttl).Err()
|
|
}
|
|
|
|
func (c *gatewayCache) GetGrokVideoPendingBilling(ctx context.Context, key string) ([]byte, error) {
|
|
if c == nil || c.rdb == nil {
|
|
return nil, errors.New("gateway cache unavailable")
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return nil, errors.New("invalid grok video pending billing key")
|
|
}
|
|
val, err := c.rdb.Get(ctx, grokVideoPendingBillingPrefix+key).Bytes()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
func (c *gatewayCache) ClaimGrokVideoBilled(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
|
if c == nil || c.rdb == nil {
|
|
return false, errors.New("gateway cache unavailable")
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return false, errors.New("invalid grok video billed key")
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = 48 * time.Hour
|
|
}
|
|
return c.rdb.SetNX(ctx, grokVideoBilledPrefix+key, "1", ttl).Result()
|
|
}
|
|
|
|
func (c *gatewayCache) ReleaseGrokVideoBilled(ctx context.Context, key string) error {
|
|
if c == nil || c.rdb == nil {
|
|
return errors.New("gateway cache unavailable")
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return errors.New("invalid grok video billed key")
|
|
}
|
|
return c.rdb.Del(ctx, grokVideoBilledPrefix+key).Err()
|
|
}
|
|
|
|
// Compile-time assertion: gatewayCache must implement CyberSessionBlockStore.
|
|
var _ service.CyberSessionBlockStore = (*gatewayCache)(nil)
|
|
var _ service.LiveCallStore = (*gatewayCache)(nil)
|
|
|
|
const reasoningContentPrefix = "reasoning_content:"
|
|
|
|
// reasoningContentDefaultTTL 是 reasoning 缓存的默认过期时间。Codex 会话可能
|
|
// 跨多天恢复,取 7 天;调用方传入非正 TTL 时兜底。
|
|
const reasoningContentDefaultTTL = 7 * 24 * time.Hour
|
|
|
|
// SetReasoningContent 按 reasoning item id 缓存 reasoning 全文。
|
|
// itemID 或 content 为空时直接返回 nil(无可缓存内容,属正常情况而非错误)。
|
|
func (c *gatewayCache) SetReasoningContent(ctx context.Context, itemID string, content string, ttl time.Duration) error {
|
|
if c == nil || c.rdb == nil {
|
|
return errors.New("gateway cache unavailable")
|
|
}
|
|
itemID = strings.TrimSpace(itemID)
|
|
if itemID == "" || content == "" {
|
|
return nil
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = reasoningContentDefaultTTL
|
|
}
|
|
return c.rdb.Set(ctx, reasoningContentPrefix+itemID, content, ttl).Err()
|
|
}
|
|
|
|
// GetReasoningContent 返回缓存的 reasoning 全文;未命中返回
|
|
// service.ErrReasoningContentNotFound。
|
|
func (c *gatewayCache) GetReasoningContent(ctx context.Context, itemID string) (string, error) {
|
|
if c == nil || c.rdb == nil {
|
|
return "", errors.New("gateway cache unavailable")
|
|
}
|
|
itemID = strings.TrimSpace(itemID)
|
|
if itemID == "" {
|
|
return "", service.ErrReasoningContentNotFound
|
|
}
|
|
val, err := c.rdb.Get(ctx, reasoningContentPrefix+itemID).Result()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
return "", service.ErrReasoningContentNotFound
|
|
}
|
|
return "", err
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
const (
|
|
cyberSessionBlockPrefix = "cyber_session_block:"
|
|
cyberSessionScopePrefix = "cyber_session_scope:"
|
|
cyberSessionRedisCommandMaxKeys = 128
|
|
)
|
|
|
|
// SetCyberSessionBlocked writes exact blocks in bounded transactions. The
|
|
// coarse scope is activated only after all exact blocks have been stored.
|
|
func (c *gatewayCache) SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error {
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
exactKeys := make([]string, 0, cyberSessionRedisCommandMaxKeys)
|
|
flush := func() error {
|
|
if len(exactKeys) == 0 {
|
|
return nil
|
|
}
|
|
pipe := c.rdb.TxPipeline()
|
|
for _, key := range exactKeys {
|
|
pipe.Set(ctx, cyberSessionBlockPrefix+key, "1", ttl)
|
|
}
|
|
_, err := pipe.Exec(ctx)
|
|
exactKeys = exactKeys[:0]
|
|
return err
|
|
}
|
|
for _, key := range keys {
|
|
if key != "" {
|
|
exactKeys = append(exactKeys, key)
|
|
if len(exactKeys) == cyberSessionRedisCommandMaxKeys {
|
|
if err := flush(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if err := flush(); err != nil {
|
|
return err
|
|
}
|
|
if scopeKey != "" {
|
|
return c.rdb.Set(ctx, cyberSessionScopePrefix+scopeKey, "1", ttl).Err()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *gatewayCache) IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error) {
|
|
n, err := c.rdb.Exists(ctx, cyberSessionScopePrefix+scopeKey).Result()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// FindCyberSessionBlocked checks bounded batches in caller order and stops at
|
|
// the first blocked key, preserving the original earliest-match behavior.
|
|
func (c *gatewayCache) FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error) {
|
|
if len(keys) == 0 {
|
|
return "", nil
|
|
}
|
|
for start := 0; start < len(keys); start += cyberSessionRedisCommandMaxKeys {
|
|
end := start + cyberSessionRedisCommandMaxKeys
|
|
if end > len(keys) {
|
|
end = len(keys)
|
|
}
|
|
redisKeys := make([]string, end-start)
|
|
for i, key := range keys[start:end] {
|
|
redisKeys[i] = cyberSessionBlockPrefix + key
|
|
}
|
|
values, err := c.rdb.MGet(ctx, redisKeys...).Result()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for i, value := range values {
|
|
if value != nil {
|
|
return keys[start+i], nil
|
|
}
|
|
}
|
|
}
|
|
return "", 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
|
|
}
|