mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-07 08:50: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
|
account := selection.Account
|
||||||
setOpsSelectedAccount(c, account.ID)
|
setOpsSelectedAccount(c, account.ID)
|
||||||
|
|
||||||
// 检查预热请求拦截(在账号选择后、转发前检查)
|
// 检查请求拦截(预热请求、SUGGESTION MODE等)
|
||||||
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
if account.IsInterceptWarmupEnabled() {
|
||||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
interceptType := detectInterceptType(body)
|
||||||
selection.ReleaseFunc()
|
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. 获取账号并发槽位
|
// 3. 获取账号并发槽位
|
||||||
@@ -344,17 +347,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
account := selection.Account
|
account := selection.Account
|
||||||
setOpsSelectedAccount(c, account.ID)
|
setOpsSelectedAccount(c, account.ID)
|
||||||
|
|
||||||
// 检查预热请求拦截(在账号选择后、转发前检查)
|
// 检查请求拦截(预热请求、SUGGESTION MODE等)
|
||||||
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
if account.IsInterceptWarmupEnabled() {
|
||||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
interceptType := detectInterceptType(body)
|
||||||
selection.ReleaseFunc()
|
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. 获取账号并发槽位
|
// 3. 获取账号并发槽位
|
||||||
@@ -765,17 +771,30 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isWarmupRequest 检测是否为预热请求(标题生成、Warmup等)
|
// InterceptType 表示请求拦截类型
|
||||||
func isWarmupRequest(body []byte) bool {
|
type InterceptType int
|
||||||
// 快速检查:如果body不包含关键字,直接返回false
|
|
||||||
|
const (
|
||||||
|
InterceptTypeNone InterceptType = iota
|
||||||
|
InterceptTypeWarmup // 预热请求(返回 "New Conversation")
|
||||||
|
InterceptTypeSuggestionMode // SUGGESTION MODE(返回空字符串)
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectInterceptType 检测请求是否需要拦截,返回拦截类型
|
||||||
|
func detectInterceptType(body []byte) InterceptType {
|
||||||
|
// 快速检查:如果不包含任何关键字,直接返回
|
||||||
bodyStr := string(body)
|
bodyStr := string(body)
|
||||||
if !strings.Contains(bodyStr, "title") && !strings.Contains(bodyStr, "Warmup") {
|
hasSuggestionMode := strings.Contains(bodyStr, "[SUGGESTION MODE:")
|
||||||
return false
|
hasWarmupKeyword := strings.Contains(bodyStr, "title") || strings.Contains(bodyStr, "Warmup")
|
||||||
|
|
||||||
|
if !hasSuggestionMode && !hasWarmupKeyword {
|
||||||
|
return InterceptTypeNone
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析完整请求
|
// 解析请求(只解析一次)
|
||||||
var req struct {
|
var req struct {
|
||||||
Messages []struct {
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
Content []struct {
|
Content []struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@@ -786,43 +805,71 @@ func isWarmupRequest(body []byte) bool {
|
|||||||
} `json:"system"`
|
} `json:"system"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &req); err != nil {
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
return false
|
return InterceptTypeNone
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 messages 中的标题提示模式
|
// 检查 SUGGESTION MODE(最后一条 user 消息)
|
||||||
for _, msg := range req.Messages {
|
if hasSuggestionMode && len(req.Messages) > 0 {
|
||||||
for _, content := range msg.Content {
|
lastMsg := req.Messages[len(req.Messages)-1]
|
||||||
if content.Type == "text" {
|
if lastMsg.Role == "user" && len(lastMsg.Content) > 0 &&
|
||||||
if strings.Contains(content.Text, "Please write a 5-10 word title for the following conversation:") ||
|
lastMsg.Content[0].Type == "text" &&
|
||||||
content.Text == "Warmup" {
|
strings.HasPrefix(lastMsg.Content[0].Text, "[SUGGESTION MODE:") {
|
||||||
return true
|
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 中的标题提取模式
|
return InterceptTypeNone
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMockWarmupStream 发送流式 mock 响应(用于预热请求拦截)
|
// sendMockInterceptStream 发送流式 mock 响应(用于请求拦截)
|
||||||
func sendMockWarmupStream(c *gin.Context, model string) {
|
func sendMockInterceptStream(c *gin.Context, model string, interceptType InterceptType) {
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Header("Connection", "keep-alive")
|
c.Header("Connection", "keep-alive")
|
||||||
c.Header("X-Accel-Buffering", "no")
|
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
|
// Build message_start event with proper JSON marshaling
|
||||||
messageStart := map[string]any{
|
messageStart := map[string]any{
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
"message": map[string]any{
|
"message": map[string]any{
|
||||||
"id": "msg_mock_warmup",
|
"id": msgID,
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -837,16 +884,46 @@ func sendMockWarmupStream(c *gin.Context, model string) {
|
|||||||
}
|
}
|
||||||
messageStartJSON, _ := json.Marshal(messageStart)
|
messageStartJSON, _ := json.Marshal(messageStart)
|
||||||
|
|
||||||
|
// Build events
|
||||||
events := []string{
|
events := []string{
|
||||||
`event: message_start` + "\n" + `data: ` + string(messageStartJSON),
|
`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_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 {
|
for _, event := range events {
|
||||||
_, _ = c.Writer.WriteString(event + "\n\n")
|
_, _ = c.Writer.WriteString(event + "\n\n")
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
@@ -854,18 +931,32 @@ func sendMockWarmupStream(c *gin.Context, model string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMockWarmupResponse 发送非流式 mock 响应(用于预热请求拦截)
|
// sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截)
|
||||||
func sendMockWarmupResponse(c *gin.Context, model string) {
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": "msg_mock_warmup",
|
"id": msgID,
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"model": model,
|
"model": model,
|
||||||
"content": []gin.H{{"type": "text", "text": "New Conversation"}},
|
"content": []gin.H{{"type": "text", "text": text}},
|
||||||
"stop_reason": "end_turn",
|
"stop_reason": "end_turn",
|
||||||
"usage": gin.H{
|
"usage": gin.H{
|
||||||
"input_tokens": 10,
|
"input_tokens": 10,
|
||||||
"output_tokens": 2,
|
"output_tokens": outputTokens,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ const (
|
|||||||
RedirectURI = "https://platform.claude.com/oauth/code/callback"
|
RedirectURI = "https://platform.claude.com/oauth/code/callback"
|
||||||
|
|
||||||
// Scopes - Browser URL (includes org:create_api_key for user authorization)
|
// 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)
|
// 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)
|
// Scopes - Setup token (inference only)
|
||||||
ScopeInference = "user:inference"
|
ScopeInference = "user:inference"
|
||||||
|
|
||||||
@@ -215,5 +215,6 @@ type OrgInfo struct {
|
|||||||
|
|
||||||
// AccountInfo represents account info from OAuth response
|
// AccountInfo represents account info from OAuth response
|
||||||
type AccountInfo struct {
|
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)
|
client := s.clientFactory(proxyURL)
|
||||||
|
|
||||||
var orgs []struct {
|
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"
|
targetURL := s.baseURL + "/api/organizations"
|
||||||
@@ -65,7 +67,23 @@ func (s *claudeOAuthService) GetOrganizationUUID(ctx context.Context, sessionKey
|
|||||||
return "", fmt.Errorf("no organizations found")
|
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
|
return orgs[0].UUID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -102,20 +103,31 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
if p.tokenCache != nil {
|
||||||
ttl := 30 * time.Minute
|
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||||
if expiresAt != nil {
|
if isStale && latestAccount != nil {
|
||||||
until := time.Until(*expiresAt)
|
// 版本过时,使用 DB 中的最新 token
|
||||||
switch {
|
slog.Debug("antigravity_token_version_stale_use_latest", "account_id", account.ID)
|
||||||
case until > antigravityTokenCacheSkew:
|
accessToken = latestAccount.GetCredential("access_token")
|
||||||
ttl = until - antigravityTokenCacheSkew
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
case until > 0:
|
return "", errors.New("access_token not found after version check")
|
||||||
ttl = until
|
|
||||||
default:
|
|
||||||
ttl = time.Minute
|
|
||||||
}
|
}
|
||||||
|
// 不写入缓存,让下次请求重新处理
|
||||||
|
} 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
|
return accessToken, nil
|
||||||
|
|||||||
@@ -182,25 +182,36 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
if p.tokenCache != nil {
|
||||||
ttl := 30 * time.Minute
|
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||||
if refreshFailed {
|
if isStale && latestAccount != nil {
|
||||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
// 版本过时,使用 DB 中的最新 token
|
||||||
ttl = time.Minute
|
slog.Debug("claude_token_version_stale_use_latest", "account_id", account.ID)
|
||||||
slog.Debug("claude_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
accessToken = latestAccount.GetCredential("access_token")
|
||||||
} else if expiresAt != nil {
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
until := time.Until(*expiresAt)
|
return "", errors.New("access_token not found after version check")
|
||||||
switch {
|
}
|
||||||
case until > claudeTokenCacheSkew:
|
// 不写入缓存,让下次请求重新处理
|
||||||
ttl = until - claudeTokenCacheSkew
|
} else {
|
||||||
case until > 0:
|
ttl := 30 * time.Minute
|
||||||
ttl = until
|
if refreshFailed {
|
||||||
default:
|
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||||
ttl = time.Minute
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -132,20 +133,31 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) Populate cache with TTL(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
// 3) Populate cache with TTL(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
if p.tokenCache != nil {
|
||||||
ttl := 30 * time.Minute
|
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||||
if expiresAt != nil {
|
if isStale && latestAccount != nil {
|
||||||
until := time.Until(*expiresAt)
|
// 版本过时,使用 DB 中的最新 token
|
||||||
switch {
|
slog.Debug("gemini_token_version_stale_use_latest", "account_id", account.ID)
|
||||||
case until > geminiTokenCacheSkew:
|
accessToken = latestAccount.GetCredential("access_token")
|
||||||
ttl = until - geminiTokenCacheSkew
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
case until > 0:
|
return "", errors.New("access_token not found after version check")
|
||||||
ttl = until
|
|
||||||
default:
|
|
||||||
ttl = time.Minute
|
|
||||||
}
|
}
|
||||||
|
// 不写入缓存,让下次请求重新处理
|
||||||
|
} 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
|
return accessToken, nil
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ type TokenInfo struct {
|
|||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
OrgUUID string `json:"org_uuid,omitempty"`
|
OrgUUID string `json:"org_uuid,omitempty"`
|
||||||
AccountUUID string `json:"account_uuid,omitempty"`
|
AccountUUID string `json:"account_uuid,omitempty"`
|
||||||
|
EmailAddress string `json:"email_address,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExchangeCode exchanges authorization code for tokens
|
// ExchangeCode exchanges authorization code for tokens
|
||||||
@@ -252,9 +253,15 @@ func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerif
|
|||||||
tokenInfo.OrgUUID = tokenResp.Organization.UUID
|
tokenInfo.OrgUUID = tokenResp.Organization.UUID
|
||||||
log.Printf("[OAuth] Got org_uuid: %s", tokenInfo.OrgUUID)
|
log.Printf("[OAuth] Got org_uuid: %s", tokenInfo.OrgUUID)
|
||||||
}
|
}
|
||||||
if tokenResp.Account != nil && tokenResp.Account.UUID != "" {
|
if tokenResp.Account != nil {
|
||||||
tokenInfo.AccountUUID = tokenResp.Account.UUID
|
if tokenResp.Account.UUID != "" {
|
||||||
log.Printf("[OAuth] Got account_uuid: %s", tokenInfo.AccountUUID)
|
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
|
return tokenInfo, nil
|
||||||
|
|||||||
@@ -163,25 +163,36 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
if p.tokenCache != nil {
|
||||||
ttl := 30 * time.Minute
|
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||||
if refreshFailed {
|
if isStale && latestAccount != nil {
|
||||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
// 版本过时,使用 DB 中的最新 token
|
||||||
ttl = time.Minute
|
slog.Debug("openai_token_version_stale_use_latest", "account_id", account.ID)
|
||||||
slog.Debug("openai_token_cache_short_ttl", "account_id", account.ID, "reason", "refresh_failed")
|
accessToken = latestAccount.GetOpenAIAccessToken()
|
||||||
} else if expiresAt != nil {
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
until := time.Until(*expiresAt)
|
return "", errors.New("access_token not found after version check")
|
||||||
switch {
|
}
|
||||||
case until > openAITokenCacheSkew:
|
// 不写入缓存,让下次请求重新处理
|
||||||
ttl = until - openAITokenCacheSkew
|
} else {
|
||||||
case until > 0:
|
ttl := 30 * time.Minute
|
||||||
ttl = until
|
if refreshFailed {
|
||||||
default:
|
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||||
ttl = time.Minute
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTokenVersionStale 检查 account 的 token 版本是否已过时
|
// CheckTokenVersion 检查 account 的 token 版本是否已过时,并返回最新的 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 {
|
if account == nil || repo == nil {
|
||||||
return false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
currentVersion := account.GetCredentialAsInt64("_token_version")
|
currentVersion := account.GetCredentialAsInt64("_token_version")
|
||||||
|
|
||||||
latestAccount, err := repo.GetByID(ctx, account.ID)
|
latestAccount, err := repo.GetByID(ctx, account.ID)
|
||||||
if err != nil || latestAccount == nil {
|
if err != nil || latestAccount == nil {
|
||||||
// 查询失败,默认允许缓存
|
// 查询失败,默认允许缓存,不返回 latestAccount
|
||||||
return false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
latestVersion := latestAccount.GetCredentialAsInt64("_token_version")
|
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",
|
slog.Debug("token_version_stale_no_current_version",
|
||||||
"account_id", account.ID,
|
"account_id", account.ID,
|
||||||
"latest_version", latestVersion)
|
"latest_version", latestVersion)
|
||||||
return true
|
return latestAccount, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 情况2: 两边都没有版本号,说明从未被异步刷新过,允许缓存
|
// 情况2: 两边都没有版本号,说明从未被异步刷新过,允许缓存
|
||||||
if currentVersion == 0 && latestVersion == 0 {
|
if currentVersion == 0 && latestVersion == 0 {
|
||||||
return false
|
return latestAccount, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 情况3: 比较版本号,如果 DB 中的版本更新,当前 account 已过时
|
// 情况3: 比较版本号,如果 DB 中的版本更新,当前 account 已过时
|
||||||
@@ -105,8 +107,8 @@ func IsTokenVersionStale(ctx context.Context, account *Account, repo AccountRepo
|
|||||||
"account_id", account.ID,
|
"account_id", account.ID,
|
||||||
"current_version", currentVersion,
|
"current_version", currentVersion,
|
||||||
"latest_version", latestVersion)
|
"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)
|
require.Equal(t, int64(0), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== IsTokenVersionStale 测试 ==========
|
// ========== CheckTokenVersion 测试 ==========
|
||||||
|
|
||||||
func TestIsTokenVersionStale(t *testing.T) {
|
func TestCheckTokenVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
account *Account
|
account *Account
|
||||||
@@ -496,16 +496,16 @@ func TestIsTokenVersionStale(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// 由于 IsTokenVersionStale 接受 AccountRepository 接口,而创建完整的 mock 很繁琐
|
// 由于 CheckTokenVersion 接受 AccountRepository 接口,而创建完整的 mock 很繁琐
|
||||||
// 这里我们直接测试函数的核心逻辑来验证行为
|
// 这里我们直接测试函数的核心逻辑来验证行为
|
||||||
|
|
||||||
if tt.name == "nil_account" {
|
if tt.name == "nil_account" {
|
||||||
result := IsTokenVersionStale(context.Background(), nil, nil)
|
_, isStale := CheckTokenVersion(context.Background(), nil, nil)
|
||||||
require.Equal(t, tt.expectedStale, result)
|
require.Equal(t, tt.expectedStale, isStale)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟 IsTokenVersionStale 的核心逻辑
|
// 模拟 CheckTokenVersion 的核心逻辑
|
||||||
account := tt.account
|
account := tt.account
|
||||||
currentVersion := account.GetCredentialAsInt64("_token_version")
|
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{
|
account := &Account{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Credentials: map[string]any{"_token_version": int64(100)},
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
}
|
}
|
||||||
result := IsTokenVersionStale(context.Background(), account, nil)
|
_, isStale := CheckTokenVersion(context.Background(), account, nil)
|
||||||
require.False(t, result) // nil repo,默认允许缓存
|
require.False(t, isStale) // nil repo,默认允许缓存
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Main Status Badge -->
|
<!-- Rate Limit Display (429) - Two-line layout -->
|
||||||
<button
|
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
||||||
v-if="isTempUnschedulable"
|
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
||||||
type="button"
|
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitCountdown }}</span>
|
||||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
</div>
|
||||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
|
||||||
@click="handleTempUnschedClick"
|
<!-- Overload Display (529) - Two-line layout -->
|
||||||
>
|
<div v-else-if="isOverloaded" class="flex flex-col items-center gap-1">
|
||||||
{{ statusText }}
|
<span class="badge text-xs badge-danger">{{ t('admin.accounts.status.overloaded') }}</span>
|
||||||
</button>
|
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ overloadCountdown }}</span>
|
||||||
<span v-else :class="['badge text-xs', statusClass]">
|
</div>
|
||||||
{{ statusText }}
|
|
||||||
</span>
|
<!-- 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 -->
|
<!-- Error Info Indicator -->
|
||||||
<div v-if="hasError && account.error_message" class="group/error relative">
|
<div v-if="hasError && account.error_message" class="group/error relative">
|
||||||
@@ -42,44 +56,6 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -87,8 +63,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Account } from '@/types'
|
import type { Account } from '@/types'
|
||||||
import { formatTime } from '@/utils/format'
|
import { formatCountdownWithSuffix } from '@/utils/format'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -123,6 +98,16 @@ const hasError = computed(() => {
|
|||||||
return props.account.status === 'error'
|
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
|
// Computed: status badge class
|
||||||
const statusClass = computed(() => {
|
const statusClass = computed(() => {
|
||||||
if (hasError.value) {
|
if (hasError.value) {
|
||||||
@@ -131,7 +116,7 @@ const statusClass = computed(() => {
|
|||||||
if (isTempUnschedulable.value) {
|
if (isTempUnschedulable.value) {
|
||||||
return 'badge-warning'
|
return 'badge-warning'
|
||||||
}
|
}
|
||||||
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
|
if (!props.account.schedulable) {
|
||||||
return 'badge-gray'
|
return 'badge-gray'
|
||||||
}
|
}
|
||||||
switch (props.account.status) {
|
switch (props.account.status) {
|
||||||
@@ -157,9 +142,6 @@ const statusText = computed(() => {
|
|||||||
if (!props.account.schedulable) {
|
if (!props.account.schedulable) {
|
||||||
return t('admin.accounts.status.paused')
|
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}`)
|
return t(`admin.accounts.status.${props.account.status}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,5 +149,4 @@ const handleTempUnschedClick = () => {
|
|||||||
if (!isTempUnschedulable.value) return
|
if (!isTempUnschedulable.value) return
|
||||||
emit('show-temp-unsched', props.account)
|
emit('show-temp-unsched', props.account)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface OAuthState {
|
|||||||
export interface TokenInfo {
|
export interface TokenInfo {
|
||||||
org_uuid?: string
|
org_uuid?: string
|
||||||
account_uuid?: string
|
account_uuid?: string
|
||||||
|
email_address?: string
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +161,9 @@ export function useAccountOAuth() {
|
|||||||
if (tokenInfo.account_uuid) {
|
if (tokenInfo.account_uuid) {
|
||||||
extra.account_uuid = 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
|
return Object.keys(extra).length > 0 ? extra : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,13 @@ export default {
|
|||||||
justNow: 'Just now',
|
justNow: 'Just now',
|
||||||
minutesAgo: '{n}m ago',
|
minutesAgo: '{n}m ago',
|
||||||
hoursAgo: '{n}h 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',
|
cooldown: 'Cooldown',
|
||||||
paused: 'Paused',
|
paused: 'Paused',
|
||||||
limited: 'Limited',
|
limited: 'Limited',
|
||||||
|
rateLimited: 'Rate Limited',
|
||||||
|
overloaded: 'Overloaded',
|
||||||
tempUnschedulable: 'Temp Unschedulable',
|
tempUnschedulable: 'Temp Unschedulable',
|
||||||
rateLimitedUntil: 'Rate limited until {time}',
|
rateLimitedUntil: 'Rate limited until {time}',
|
||||||
overloadedUntil: 'Overloaded until {time}',
|
overloadedUntil: 'Overloaded until {time}',
|
||||||
|
|||||||
@@ -166,7 +166,13 @@ export default {
|
|||||||
justNow: '刚刚',
|
justNow: '刚刚',
|
||||||
minutesAgo: '{n}分钟前',
|
minutesAgo: '{n}分钟前',
|
||||||
hoursAgo: '{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: '冷却中',
|
cooldown: '冷却中',
|
||||||
paused: '暂停',
|
paused: '暂停',
|
||||||
limited: '限流',
|
limited: '限流',
|
||||||
|
rateLimited: '限流中',
|
||||||
|
overloaded: '过载中',
|
||||||
tempUnschedulable: '临时不可调度',
|
tempUnschedulable: '临时不可调度',
|
||||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||||
overloadedUntil: '负载过重,重置时间:{time}',
|
overloadedUntil: '负载过重,重置时间:{time}',
|
||||||
|
|||||||
@@ -216,3 +216,48 @@ export function formatTokensK(tokens: number): string {
|
|||||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||||
return tokens.toString()
|
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 }">
|
<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" />
|
<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>
|
||||||
<template #cell-name="{ value }">
|
<template #cell-name="{ row, value }">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<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>
|
||||||
<template #cell-notes="{ value }">
|
<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>
|
<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