Files
sub2api/backend/internal/service/openai_team_linked_error.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

100 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/tidwall/gjson"
)
const (
openAITeamLinkedErrorDedupTTL = 60 * time.Second
openAITeamLinkedErrorFanoutTimeout = 30 * time.Second
openAITeamLinkedErrorBlockReason = "team_linked_error"
)
// maybeHandleOpenAITeamLinkedError 在 OpenAI OAuth 账户收到 402 deactivated_workspace
// ChatGPT Team 工作区被停用)时,把同一 Teamcredentials.chatgpt_account_id 相同)
// 的其余 active 账户一并置为 error 并立即熔断。触发账户自身不在 fan-out 范围内,
// 仍由常规 402 处理标记。
func (s *RateLimitService) maybeHandleOpenAITeamLinkedError(ctx context.Context, account *Account, statusCode int, responseBody []byte) {
if s == nil || s.accountRepo == nil || statusCode != http.StatusPaymentRequired || !isOpenAIOAuthAccount(account) {
return
}
if gjson.GetBytes(responseBody, "detail.code").String() != "deactivated_workspace" {
return
}
teamID := strings.TrimSpace(account.GetChatGPTAccountID())
if teamID == "" {
return
}
if !s.markOpenAITeamLinkedFired(teamID) {
return
}
// 上游报错场景请求 ctx 往往已被取消,落库需要独立生命周期。
opCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAITeamLinkedErrorFanoutTimeout)
defer cancel()
accounts, err := s.accountRepo.ListByPlatform(opCtx, PlatformOpenAI)
if err != nil {
slog.Warn("openai_team_linked_error_list_failed", "trigger_account_id", account.ID, "error", err)
return
}
var targets []*Account
for i := range accounts {
acc := &accounts[i]
if acc.ID == account.ID || acc.IsShadow() || strings.TrimSpace(acc.GetChatGPTAccountID()) != teamID {
continue
}
targets = append(targets, acc)
}
if len(targets) == 0 {
return
}
// 先全部进程内熔断(微秒级生效),再逐个落库,避免后面的账户等待前面的 DB 写入。
for _, acc := range targets {
s.notifyAccountSchedulingBlocked(acc, time.Time{}, openAITeamLinkedErrorBlockReason)
}
errorMsg := fmt.Sprintf("Workspace deactivated (402): team-linked error triggered by account #%d", account.ID)
marked := 0
for _, acc := range targets {
// 单账户写入失败不中断其余账户;进程内熔断已先行,且该账户仍为 active,
// 下一个 402 在去重 TTL 过期后会重新触发 fan-out。
if err := s.accountRepo.SetError(opCtx, acc.ID, errorMsg); err != nil {
slog.Warn("openai_team_linked_error_set_error_failed", "account_id", acc.ID, "error", err)
continue
}
marked++
}
slog.Warn("openai_team_linked_error_fanout",
"trigger_account_id", account.ID,
"chatgpt_account_id", teamID,
"affected", marked,
"targets", len(targets),
)
}
// markOpenAITeamLinkedFired 以 teamID 为键做进程内去重:TTL 内同一 Team 只允许一次 fan-out。
func (s *RateLimitService) markOpenAITeamLinkedFired(teamID string) bool {
now := time.Now()
s.openaiTeamLinkedMu.Lock()
defer s.openaiTeamLinkedMu.Unlock()
if expiry, ok := s.openaiTeamLinkedRecent[teamID]; ok && expiry.After(now) {
return false
}
if s.openaiTeamLinkedRecent == nil {
s.openaiTeamLinkedRecent = make(map[string]time.Time)
}
for k, v := range s.openaiTeamLinkedRecent {
if !v.After(now) {
delete(s.openaiTeamLinkedRecent, k)
}
}
s.openaiTeamLinkedRecent[teamID] = now.Add(openAITeamLinkedErrorDedupTTL)
return true
}