mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-06 16:30:22 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a0fe3b143 | ||
|
|
a1292fac81 | ||
|
|
7f98be4f91 | ||
|
|
fd73b8875d | ||
|
|
f9ab1daa3c | ||
|
|
d27b847442 | ||
|
|
dac6bc2228 |
@@ -209,17 +209,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
account := selection.Account
|
||||
setOpsSelectedAccount(c, account.ID)
|
||||
|
||||
// 检查预热请求拦截(在账号选择后、转发前检查)
|
||||
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
// 检查请求拦截(预热请求、SUGGESTION MODE等)
|
||||
if account.IsInterceptWarmupEnabled() {
|
||||
interceptType := detectInterceptType(body)
|
||||
if interceptType != InterceptTypeNone {
|
||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
if reqStream {
|
||||
sendMockInterceptStream(c, reqModel, interceptType)
|
||||
} else {
|
||||
sendMockInterceptResponse(c, reqModel, interceptType)
|
||||
}
|
||||
return
|
||||
}
|
||||
if reqStream {
|
||||
sendMockWarmupStream(c, reqModel)
|
||||
} else {
|
||||
sendMockWarmupResponse(c, reqModel)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 获取账号并发槽位
|
||||
@@ -344,17 +347,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
account := selection.Account
|
||||
setOpsSelectedAccount(c, account.ID)
|
||||
|
||||
// 检查预热请求拦截(在账号选择后、转发前检查)
|
||||
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
// 检查请求拦截(预热请求、SUGGESTION MODE等)
|
||||
if account.IsInterceptWarmupEnabled() {
|
||||
interceptType := detectInterceptType(body)
|
||||
if interceptType != InterceptTypeNone {
|
||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
if reqStream {
|
||||
sendMockInterceptStream(c, reqModel, interceptType)
|
||||
} else {
|
||||
sendMockInterceptResponse(c, reqModel, interceptType)
|
||||
}
|
||||
return
|
||||
}
|
||||
if reqStream {
|
||||
sendMockWarmupStream(c, reqModel)
|
||||
} else {
|
||||
sendMockWarmupResponse(c, reqModel)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 获取账号并发槽位
|
||||
@@ -765,17 +771,30 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// isWarmupRequest 检测是否为预热请求(标题生成、Warmup等)
|
||||
func isWarmupRequest(body []byte) bool {
|
||||
// 快速检查:如果body不包含关键字,直接返回false
|
||||
// InterceptType 表示请求拦截类型
|
||||
type InterceptType int
|
||||
|
||||
const (
|
||||
InterceptTypeNone InterceptType = iota
|
||||
InterceptTypeWarmup // 预热请求(返回 "New Conversation")
|
||||
InterceptTypeSuggestionMode // SUGGESTION MODE(返回空字符串)
|
||||
)
|
||||
|
||||
// detectInterceptType 检测请求是否需要拦截,返回拦截类型
|
||||
func detectInterceptType(body []byte) InterceptType {
|
||||
// 快速检查:如果不包含任何关键字,直接返回
|
||||
bodyStr := string(body)
|
||||
if !strings.Contains(bodyStr, "title") && !strings.Contains(bodyStr, "Warmup") {
|
||||
return false
|
||||
hasSuggestionMode := strings.Contains(bodyStr, "[SUGGESTION MODE:")
|
||||
hasWarmupKeyword := strings.Contains(bodyStr, "title") || strings.Contains(bodyStr, "Warmup")
|
||||
|
||||
if !hasSuggestionMode && !hasWarmupKeyword {
|
||||
return InterceptTypeNone
|
||||
}
|
||||
|
||||
// 解析完整请求
|
||||
// 解析请求(只解析一次)
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
@@ -786,43 +805,71 @@ func isWarmupRequest(body []byte) bool {
|
||||
} `json:"system"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return false
|
||||
return InterceptTypeNone
|
||||
}
|
||||
|
||||
// 检查 messages 中的标题提示模式
|
||||
for _, msg := range req.Messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.Type == "text" {
|
||||
if strings.Contains(content.Text, "Please write a 5-10 word title for the following conversation:") ||
|
||||
content.Text == "Warmup" {
|
||||
return true
|
||||
// 检查 SUGGESTION MODE(最后一条 user 消息)
|
||||
if hasSuggestionMode && len(req.Messages) > 0 {
|
||||
lastMsg := req.Messages[len(req.Messages)-1]
|
||||
if lastMsg.Role == "user" && len(lastMsg.Content) > 0 &&
|
||||
lastMsg.Content[0].Type == "text" &&
|
||||
strings.HasPrefix(lastMsg.Content[0].Text, "[SUGGESTION MODE:") {
|
||||
return InterceptTypeSuggestionMode
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 Warmup 请求
|
||||
if hasWarmupKeyword {
|
||||
// 检查 messages 中的标题提示模式
|
||||
for _, msg := range req.Messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.Type == "text" {
|
||||
if strings.Contains(content.Text, "Please write a 5-10 word title for the following conversation:") ||
|
||||
content.Text == "Warmup" {
|
||||
return InterceptTypeWarmup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 检查 system 中的标题提取模式
|
||||
for _, sys := range req.System {
|
||||
if strings.Contains(sys.Text, "nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title") {
|
||||
return InterceptTypeWarmup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 system 中的标题提取模式
|
||||
for _, system := range req.System {
|
||||
if strings.Contains(system.Text, "nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return InterceptTypeNone
|
||||
}
|
||||
|
||||
// sendMockWarmupStream 发送流式 mock 响应(用于预热请求拦截)
|
||||
func sendMockWarmupStream(c *gin.Context, model string) {
|
||||
// sendMockInterceptStream 发送流式 mock 响应(用于请求拦截)
|
||||
func sendMockInterceptStream(c *gin.Context, model string, interceptType InterceptType) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
// 根据拦截类型决定响应内容
|
||||
var msgID string
|
||||
var outputTokens int
|
||||
var textDeltas []string
|
||||
|
||||
switch interceptType {
|
||||
case InterceptTypeSuggestionMode:
|
||||
msgID = "msg_mock_suggestion"
|
||||
outputTokens = 1
|
||||
textDeltas = []string{""} // 空内容
|
||||
default: // InterceptTypeWarmup
|
||||
msgID = "msg_mock_warmup"
|
||||
outputTokens = 2
|
||||
textDeltas = []string{"New", " Conversation"}
|
||||
}
|
||||
|
||||
// Build message_start event with proper JSON marshaling
|
||||
messageStart := map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": "msg_mock_warmup",
|
||||
"id": msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
@@ -837,16 +884,46 @@ func sendMockWarmupStream(c *gin.Context, model string) {
|
||||
}
|
||||
messageStartJSON, _ := json.Marshal(messageStart)
|
||||
|
||||
// Build events
|
||||
events := []string{
|
||||
`event: message_start` + "\n" + `data: ` + string(messageStartJSON),
|
||||
`event: content_block_start` + "\n" + `data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`,
|
||||
`event: content_block_delta` + "\n" + `data: {"delta":{"text":"New","type":"text_delta"},"index":0,"type":"content_block_delta"}`,
|
||||
`event: content_block_delta` + "\n" + `data: {"delta":{"text":" Conversation","type":"text_delta"},"index":0,"type":"content_block_delta"}`,
|
||||
`event: content_block_stop` + "\n" + `data: {"index":0,"type":"content_block_stop"}`,
|
||||
`event: message_delta` + "\n" + `data: {"delta":{"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"input_tokens":10,"output_tokens":2}}`,
|
||||
`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
|
||||
}
|
||||
|
||||
// Add text deltas
|
||||
for _, text := range textDeltas {
|
||||
delta := map[string]any{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": map[string]string{
|
||||
"type": "text_delta",
|
||||
"text": text,
|
||||
},
|
||||
}
|
||||
deltaJSON, _ := json.Marshal(delta)
|
||||
events = append(events, `event: content_block_delta`+"\n"+`data: `+string(deltaJSON))
|
||||
}
|
||||
|
||||
// Add final events
|
||||
messageDelta := map[string]any{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]any{
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": nil,
|
||||
},
|
||||
"usage": map[string]int{
|
||||
"input_tokens": 10,
|
||||
"output_tokens": outputTokens,
|
||||
},
|
||||
}
|
||||
messageDeltaJSON, _ := json.Marshal(messageDelta)
|
||||
|
||||
events = append(events,
|
||||
`event: content_block_stop`+"\n"+`data: {"index":0,"type":"content_block_stop"}`,
|
||||
`event: message_delta`+"\n"+`data: `+string(messageDeltaJSON),
|
||||
`event: message_stop`+"\n"+`data: {"type":"message_stop"}`,
|
||||
)
|
||||
|
||||
for _, event := range events {
|
||||
_, _ = c.Writer.WriteString(event + "\n\n")
|
||||
c.Writer.Flush()
|
||||
@@ -854,18 +931,32 @@ func sendMockWarmupStream(c *gin.Context, model string) {
|
||||
}
|
||||
}
|
||||
|
||||
// sendMockWarmupResponse 发送非流式 mock 响应(用于预热请求拦截)
|
||||
func sendMockWarmupResponse(c *gin.Context, model string) {
|
||||
// sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截)
|
||||
func sendMockInterceptResponse(c *gin.Context, model string, interceptType InterceptType) {
|
||||
var msgID, text string
|
||||
var outputTokens int
|
||||
|
||||
switch interceptType {
|
||||
case InterceptTypeSuggestionMode:
|
||||
msgID = "msg_mock_suggestion"
|
||||
text = ""
|
||||
outputTokens = 1
|
||||
default: // InterceptTypeWarmup
|
||||
msgID = "msg_mock_warmup"
|
||||
text = "New Conversation"
|
||||
outputTokens = 2
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": "msg_mock_warmup",
|
||||
"id": msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": []gin.H{{"type": "text", "text": "New Conversation"}},
|
||||
"content": []gin.H{{"type": "text", "text": text}},
|
||||
"stop_reason": "end_turn",
|
||||
"usage": gin.H{
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 2,
|
||||
"output_tokens": outputTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ const (
|
||||
RedirectURI = "https://platform.claude.com/oauth/code/callback"
|
||||
|
||||
// Scopes - Browser URL (includes org:create_api_key for user authorization)
|
||||
ScopeOAuth = "org:create_api_key user:profile user:inference user:sessions:claude_code"
|
||||
ScopeOAuth = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers"
|
||||
// Scopes - Internal API call (org:create_api_key not supported in API)
|
||||
ScopeAPI = "user:profile user:inference user:sessions:claude_code"
|
||||
ScopeAPI = "user:profile user:inference user:sessions:claude_code user:mcp_servers"
|
||||
// Scopes - Setup token (inference only)
|
||||
ScopeInference = "user:inference"
|
||||
|
||||
@@ -215,5 +215,6 @@ type OrgInfo struct {
|
||||
|
||||
// AccountInfo represents account info from OAuth response
|
||||
type AccountInfo struct {
|
||||
UUID string `json:"uuid"`
|
||||
UUID string `json:"uuid"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ func (s *claudeOAuthService) GetOrganizationUUID(ctx context.Context, sessionKey
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
var orgs []struct {
|
||||
UUID string `json:"uuid"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
RavenType *string `json:"raven_type"` // nil for personal, "team" for team organization
|
||||
}
|
||||
|
||||
targetURL := s.baseURL + "/api/organizations"
|
||||
@@ -65,7 +67,23 @@ func (s *claudeOAuthService) GetOrganizationUUID(ctx context.Context, sessionKey
|
||||
return "", fmt.Errorf("no organizations found")
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 1 SUCCESS - Got org UUID: %s", orgs[0].UUID)
|
||||
// 如果只有一个组织,直接使用
|
||||
if len(orgs) == 1 {
|
||||
log.Printf("[OAuth] Step 1 SUCCESS - Single org found, UUID: %s, Name: %s", orgs[0].UUID, orgs[0].Name)
|
||||
return orgs[0].UUID, nil
|
||||
}
|
||||
|
||||
// 如果有多个组织,优先选择 raven_type 为 "team" 的组织
|
||||
for _, org := range orgs {
|
||||
if org.RavenType != nil && *org.RavenType == "team" {
|
||||
log.Printf("[OAuth] Step 1 SUCCESS - Selected team org, UUID: %s, Name: %s, RavenType: %s",
|
||||
org.UUID, org.Name, *org.RavenType)
|
||||
return org.UUID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有 team 类型的组织,使用第一个
|
||||
log.Printf("[OAuth] Step 1 SUCCESS - No team org found, using first org, UUID: %s, Name: %s", orgs[0].UUID, orgs[0].Name)
|
||||
return orgs[0].UUID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -102,20 +103,31 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
|
||||
}
|
||||
|
||||
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
||||
ttl := 30 * time.Minute
|
||||
if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > antigravityTokenCacheSkew:
|
||||
ttl = until - antigravityTokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
if p.tokenCache != nil {
|
||||
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||
if isStale && latestAccount != nil {
|
||||
// 版本过时,使用 DB 中的最新 token
|
||||
slog.Debug("antigravity_token_version_stale_use_latest", "account_id", account.ID)
|
||||
accessToken = latestAccount.GetCredential("access_token")
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return "", errors.New("access_token not found after version check")
|
||||
}
|
||||
// 不写入缓存,让下次请求重新处理
|
||||
} else {
|
||||
ttl := 30 * time.Minute
|
||||
if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > antigravityTokenCacheSkew:
|
||||
ttl = until - antigravityTokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
}
|
||||
}
|
||||
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||
}
|
||||
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
|
||||
@@ -182,25 +182,36 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
||||
}
|
||||
|
||||
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
||||
ttl := 30 * time.Minute
|
||||
if refreshFailed {
|
||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||
ttl = time.Minute
|
||||
slog.Debug("claude_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
||||
} else if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > claudeTokenCacheSkew:
|
||||
ttl = until - claudeTokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
if p.tokenCache != nil {
|
||||
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||
if isStale && latestAccount != nil {
|
||||
// 版本过时,使用 DB 中的最新 token
|
||||
slog.Debug("claude_token_version_stale_use_latest", "account_id", account.ID)
|
||||
accessToken = latestAccount.GetCredential("access_token")
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return "", errors.New("access_token not found after version check")
|
||||
}
|
||||
// 不写入缓存,让下次请求重新处理
|
||||
} else {
|
||||
ttl := 30 * time.Minute
|
||||
if refreshFailed {
|
||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||
ttl = time.Minute
|
||||
slog.Debug("claude_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
||||
} else if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > claudeTokenCacheSkew:
|
||||
ttl = until - claudeTokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
}
|
||||
}
|
||||
if err := p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl); err != nil {
|
||||
slog.Warn("claude_token_cache_set_failed", "account_id", account.ID, "error", err)
|
||||
}
|
||||
}
|
||||
if err := p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl); err != nil {
|
||||
slog.Warn("claude_token_cache_set_failed", "account_id", account.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -132,20 +133,31 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
||||
}
|
||||
|
||||
// 3) Populate cache with TTL(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
||||
ttl := 30 * time.Minute
|
||||
if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > geminiTokenCacheSkew:
|
||||
ttl = until - geminiTokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
if p.tokenCache != nil {
|
||||
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||
if isStale && latestAccount != nil {
|
||||
// 版本过时,使用 DB 中的最新 token
|
||||
slog.Debug("gemini_token_version_stale_use_latest", "account_id", account.ID)
|
||||
accessToken = latestAccount.GetCredential("access_token")
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return "", errors.New("access_token not found after version check")
|
||||
}
|
||||
// 不写入缓存,让下次请求重新处理
|
||||
} else {
|
||||
ttl := 30 * time.Minute
|
||||
if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > geminiTokenCacheSkew:
|
||||
ttl = until - geminiTokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
}
|
||||
}
|
||||
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||
}
|
||||
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
|
||||
@@ -122,6 +122,7 @@ type TokenInfo struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
OrgUUID string `json:"org_uuid,omitempty"`
|
||||
AccountUUID string `json:"account_uuid,omitempty"`
|
||||
EmailAddress string `json:"email_address,omitempty"`
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges authorization code for tokens
|
||||
@@ -252,9 +253,15 @@ func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerif
|
||||
tokenInfo.OrgUUID = tokenResp.Organization.UUID
|
||||
log.Printf("[OAuth] Got org_uuid: %s", tokenInfo.OrgUUID)
|
||||
}
|
||||
if tokenResp.Account != nil && tokenResp.Account.UUID != "" {
|
||||
tokenInfo.AccountUUID = tokenResp.Account.UUID
|
||||
log.Printf("[OAuth] Got account_uuid: %s", tokenInfo.AccountUUID)
|
||||
if tokenResp.Account != nil {
|
||||
if tokenResp.Account.UUID != "" {
|
||||
tokenInfo.AccountUUID = tokenResp.Account.UUID
|
||||
log.Printf("[OAuth] Got account_uuid: %s", tokenInfo.AccountUUID)
|
||||
}
|
||||
if tokenResp.Account.EmailAddress != "" {
|
||||
tokenInfo.EmailAddress = tokenResp.Account.EmailAddress
|
||||
log.Printf("[OAuth] Got email_address: %s", tokenInfo.EmailAddress)
|
||||
}
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
|
||||
@@ -163,25 +163,36 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
||||
}
|
||||
|
||||
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
||||
ttl := 30 * time.Minute
|
||||
if refreshFailed {
|
||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||
ttl = time.Minute
|
||||
slog.Debug("openai_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
||||
} else if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > openAITokenCacheSkew:
|
||||
ttl = until - openAITokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
if p.tokenCache != nil {
|
||||
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||
if isStale && latestAccount != nil {
|
||||
// 版本过时,使用 DB 中的最新 token
|
||||
slog.Debug("openai_token_version_stale_use_latest", "account_id", account.ID)
|
||||
accessToken = latestAccount.GetOpenAIAccessToken()
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return "", errors.New("access_token not found after version check")
|
||||
}
|
||||
// 不写入缓存,让下次请求重新处理
|
||||
} else {
|
||||
ttl := 30 * time.Minute
|
||||
if refreshFailed {
|
||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||
ttl = time.Minute
|
||||
slog.Debug("openai_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
||||
} else if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > openAITokenCacheSkew:
|
||||
ttl = until - openAITokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
}
|
||||
}
|
||||
if err := p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl); err != nil {
|
||||
slog.Warn("openai_token_cache_set_failed", "account_id", account.ID, "error", err)
|
||||
}
|
||||
}
|
||||
if err := p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl); err != nil {
|
||||
slog.Warn("openai_token_cache_set_failed", "account_id", account.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,22 +65,24 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTokenVersionStale 检查 account 的 token 版本是否已过时
|
||||
// CheckTokenVersion 检查 account 的 token 版本是否已过时,并返回最新的 account
|
||||
// 用于解决异步刷新任务与请求线程的竞态条件:
|
||||
// 如果刷新任务已更新 token 并删除缓存,此时请求线程的旧 account 对象不应写入缓存
|
||||
//
|
||||
// 返回 true 表示 token 已过时(不应缓存),false 表示可以缓存
|
||||
func IsTokenVersionStale(ctx context.Context, account *Account, repo AccountRepository) bool {
|
||||
// 返回值:
|
||||
// - latestAccount: 从 DB 获取的最新 account(如果查询失败则返回 nil)
|
||||
// - isStale: true 表示 token 已过时(应使用 latestAccount),false 表示可以使用当前 account
|
||||
func CheckTokenVersion(ctx context.Context, account *Account, repo AccountRepository) (latestAccount *Account, isStale bool) {
|
||||
if account == nil || repo == nil {
|
||||
return false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
currentVersion := account.GetCredentialAsInt64("_token_version")
|
||||
|
||||
latestAccount, err := repo.GetByID(ctx, account.ID)
|
||||
if err != nil || latestAccount == nil {
|
||||
// 查询失败,默认允许缓存
|
||||
return false
|
||||
// 查询失败,默认允许缓存,不返回 latestAccount
|
||||
return nil, false
|
||||
}
|
||||
|
||||
latestVersion := latestAccount.GetCredentialAsInt64("_token_version")
|
||||
@@ -91,12 +93,12 @@ func IsTokenVersionStale(ctx context.Context, account *Account, repo AccountRepo
|
||||
slog.Debug("token_version_stale_no_current_version",
|
||||
"account_id", account.ID,
|
||||
"latest_version", latestVersion)
|
||||
return true
|
||||
return latestAccount, true
|
||||
}
|
||||
|
||||
// 情况2: 两边都没有版本号,说明从未被异步刷新过,允许缓存
|
||||
if currentVersion == 0 && latestVersion == 0 {
|
||||
return false
|
||||
return latestAccount, false
|
||||
}
|
||||
|
||||
// 情况3: 比较版本号,如果 DB 中的版本更新,当前 account 已过时
|
||||
@@ -105,8 +107,8 @@ func IsTokenVersionStale(ctx context.Context, account *Account, repo AccountRepo
|
||||
"account_id", account.ID,
|
||||
"current_version", currentVersion,
|
||||
"latest_version", latestVersion)
|
||||
return true
|
||||
return latestAccount, true
|
||||
}
|
||||
|
||||
return false
|
||||
return latestAccount, false
|
||||
}
|
||||
|
||||
@@ -396,9 +396,9 @@ func TestAccount_GetCredentialAsInt64_NilAccount(t *testing.T) {
|
||||
require.Equal(t, int64(0), result)
|
||||
}
|
||||
|
||||
// ========== IsTokenVersionStale 测试 ==========
|
||||
// ========== CheckTokenVersion 测试 ==========
|
||||
|
||||
func TestIsTokenVersionStale(t *testing.T) {
|
||||
func TestCheckTokenVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
@@ -496,16 +496,16 @@ func TestIsTokenVersionStale(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 由于 IsTokenVersionStale 接受 AccountRepository 接口,而创建完整的 mock 很繁琐
|
||||
// 由于 CheckTokenVersion 接受 AccountRepository 接口,而创建完整的 mock 很繁琐
|
||||
// 这里我们直接测试函数的核心逻辑来验证行为
|
||||
|
||||
if tt.name == "nil_account" {
|
||||
result := IsTokenVersionStale(context.Background(), nil, nil)
|
||||
require.Equal(t, tt.expectedStale, result)
|
||||
_, isStale := CheckTokenVersion(context.Background(), nil, nil)
|
||||
require.Equal(t, tt.expectedStale, isStale)
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟 IsTokenVersionStale 的核心逻辑
|
||||
// 模拟 CheckTokenVersion 的核心逻辑
|
||||
account := tt.account
|
||||
currentVersion := account.GetCredentialAsInt64("_token_version")
|
||||
|
||||
@@ -537,11 +537,11 @@ func TestIsTokenVersionStale(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTokenVersionStale_NilRepo(t *testing.T) {
|
||||
func TestCheckTokenVersion_NilRepo(t *testing.T) {
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Credentials: map[string]any{"_token_version": int64(100)},
|
||||
}
|
||||
result := IsTokenVersionStale(context.Background(), account, nil)
|
||||
require.False(t, result) // nil repo,默认允许缓存
|
||||
_, isStale := CheckTokenVersion(context.Background(), account, nil)
|
||||
require.False(t, isStale) // nil repo,默认允许缓存
|
||||
}
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Main Status Badge -->
|
||||
<button
|
||||
v-if="isTempUnschedulable"
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
</button>
|
||||
<span v-else :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<!-- Rate Limit Display (429) - Two-line layout -->
|
||||
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
||||
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitCountdown }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Overload Display (529) - Two-line layout -->
|
||||
<div v-else-if="isOverloaded" class="flex flex-col items-center gap-1">
|
||||
<span class="badge text-xs badge-danger">{{ t('admin.accounts.status.overloaded') }}</span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ overloadCountdown }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="isTempUnschedulable"
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
</button>
|
||||
<span v-else :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Error Info Indicator -->
|
||||
<div v-if="hasError && account.error_message" class="group/error relative">
|
||||
@@ -42,44 +56,6 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Indicator (429) -->
|
||||
<div v-if="isRateLimited" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
429
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overload Indicator (529) -->
|
||||
<div v-if="isOverloaded" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
529
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -87,8 +63,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatCountdownWithSuffix } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -123,6 +98,16 @@ const hasError = computed(() => {
|
||||
return props.account.status === 'error'
|
||||
})
|
||||
|
||||
// Computed: countdown text for rate limit (429)
|
||||
const rateLimitCountdown = computed(() => {
|
||||
return formatCountdownWithSuffix(props.account.rate_limit_reset_at)
|
||||
})
|
||||
|
||||
// Computed: countdown text for overload (529)
|
||||
const overloadCountdown = computed(() => {
|
||||
return formatCountdownWithSuffix(props.account.overload_until)
|
||||
})
|
||||
|
||||
// Computed: status badge class
|
||||
const statusClass = computed(() => {
|
||||
if (hasError.value) {
|
||||
@@ -131,7 +116,7 @@ const statusClass = computed(() => {
|
||||
if (isTempUnschedulable.value) {
|
||||
return 'badge-warning'
|
||||
}
|
||||
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
|
||||
if (!props.account.schedulable) {
|
||||
return 'badge-gray'
|
||||
}
|
||||
switch (props.account.status) {
|
||||
@@ -157,9 +142,6 @@ const statusText = computed(() => {
|
||||
if (!props.account.schedulable) {
|
||||
return t('admin.accounts.status.paused')
|
||||
}
|
||||
if (isRateLimited.value || isOverloaded.value) {
|
||||
return t('admin.accounts.status.limited')
|
||||
}
|
||||
return t(`admin.accounts.status.${props.account.status}`)
|
||||
})
|
||||
|
||||
@@ -167,5 +149,4 @@ const handleTempUnschedClick = () => {
|
||||
if (!isTempUnschedulable.value) return
|
||||
emit('show-temp-unsched', props.account)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface OAuthState {
|
||||
export interface TokenInfo {
|
||||
org_uuid?: string
|
||||
account_uuid?: string
|
||||
email_address?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -160,6 +161,9 @@ export function useAccountOAuth() {
|
||||
if (tokenInfo.account_uuid) {
|
||||
extra.account_uuid = tokenInfo.account_uuid
|
||||
}
|
||||
if (tokenInfo.email_address) {
|
||||
extra.email_address = tokenInfo.email_address
|
||||
}
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,13 @@ export default {
|
||||
justNow: 'Just now',
|
||||
minutesAgo: '{n}m ago',
|
||||
hoursAgo: '{n}h ago',
|
||||
daysAgo: '{n}d ago'
|
||||
daysAgo: '{n}d ago',
|
||||
countdown: {
|
||||
daysHours: '{d}d {h}h',
|
||||
hoursMinutes: '{h}h {m}m',
|
||||
minutes: '{m}m',
|
||||
withSuffix: '{time} to lift'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1090,6 +1096,8 @@ export default {
|
||||
cooldown: 'Cooldown',
|
||||
paused: 'Paused',
|
||||
limited: 'Limited',
|
||||
rateLimited: 'Rate Limited',
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited until {time}',
|
||||
overloadedUntil: 'Overloaded until {time}',
|
||||
|
||||
@@ -166,7 +166,13 @@ export default {
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{n}分钟前',
|
||||
hoursAgo: '{n}小时前',
|
||||
daysAgo: '{n}天前'
|
||||
daysAgo: '{n}天前',
|
||||
countdown: {
|
||||
daysHours: '{d}d {h}h',
|
||||
hoursMinutes: '{h}h {m}m',
|
||||
minutes: '{m}m',
|
||||
withSuffix: '{time} 后解除'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1212,6 +1218,8 @@ export default {
|
||||
cooldown: '冷却中',
|
||||
paused: '暂停',
|
||||
limited: '限流',
|
||||
rateLimited: '限流中',
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
|
||||
@@ -216,3 +216,48 @@ export function formatTokensK(tokens: number): string {
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时(从现在到目标时间的剩余时间)
|
||||
* @param targetDate 目标日期字符串或 Date 对象
|
||||
* @returns 倒计时字符串,如 "2h 41m", "3d 5h", "15m"
|
||||
*/
|
||||
export function formatCountdown(targetDate: string | Date | null | undefined): string | null {
|
||||
if (!targetDate) return null
|
||||
|
||||
const now = new Date()
|
||||
const target = new Date(targetDate)
|
||||
const diffMs = target.getTime() - now.getTime()
|
||||
|
||||
// 如果目标时间已过或无效
|
||||
if (diffMs <= 0 || isNaN(diffMs)) return null
|
||||
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
const remainingHours = diffHours % 24
|
||||
const remainingMins = diffMins % 60
|
||||
|
||||
if (diffDays > 0) {
|
||||
// 超过1天:显示 "Xd Yh"
|
||||
return i18n.global.t('common.time.countdown.daysHours', { d: diffDays, h: remainingHours })
|
||||
}
|
||||
if (diffHours > 0) {
|
||||
// 小于1天:显示 "Xh Ym"
|
||||
return i18n.global.t('common.time.countdown.hoursMinutes', { h: diffHours, m: remainingMins })
|
||||
}
|
||||
// 小于1小时:显示 "Ym"
|
||||
return i18n.global.t('common.time.countdown.minutes', { m: diffMins })
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时并带后缀(如 "2h 41m 后解除")
|
||||
* @param targetDate 目标日期字符串或 Date 对象
|
||||
* @returns 完整的倒计时字符串,如 "2h 41m to lift", "2小时41分钟后解除"
|
||||
*/
|
||||
export function formatCountdownWithSuffix(targetDate: string | Date | null | undefined): string | null {
|
||||
const countdown = formatCountdown(targetDate)
|
||||
if (!countdown) return null
|
||||
return i18n.global.t('common.time.countdown.withSuffix', { time: countdown })
|
||||
}
|
||||
|
||||
@@ -113,8 +113,17 @@
|
||||
<template #cell-select="{ row }">
|
||||
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</template>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<template #cell-name="{ row, value }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<span
|
||||
v-if="row.extra?.email_address"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
|
||||
:title="row.extra.email_address"
|
||||
>
|
||||
{{ row.extra.email_address }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-notes="{ value }">
|
||||
<span v-if="value" :title="value" class="block max-w-xs truncate text-sm text-gray-600 dark:text-gray-300">{{ value }}</span>
|
||||
|
||||
Reference in New Issue
Block a user