mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
背景:gpt-5.3-codex-spark 使用独立于 codex 全局(5h/7d)的配额窗口(数据源是 /wham/usage 响应体的 codex_bengalfox,而非 codex 全局用的 x-codex-* 响应头),且 只能挂在已完成 OAuth 授权的 OpenAI 账号下复用其登录态,不能作为独立账号单独接入。 为此新增“链接型影子账号”(spark shadow account):影子账号本身不持有任何凭据, 通过 parent_account_id 指向母账号,凭据/token/代理透传自母账号并共享母账号的刷新 周期,仅在配额维度(quota_dimension=spark)和用量窗口上与母账号完全独立调度、互不 连坐。 实现: - 数据模型:migration 154(+154a)给 accounts 表加 parent_account_id / quota_dimension 列 + 4 条约束(维度合法 / parent⟺非 global 维度一致 / 禁自指 / FK)+ 2 个 CONCURRENTLY 索引(母账号索引 + 每母账号至多一个影子的唯一索引)。 - 创建:POST /api/v1/admin/accounts/:id/shadow(CreateShadow)—— 一母一影(唯一 索引兜底并发竞态),继承母账号 proxy/分组/并发/优先级(显式传参可覆盖),默认 model_mapping 恒等映射到 spark(拒绝非 spark 模型),母账号必须是真实的 OpenAI OAuth 账号(非影子)。 - 凭据透传:resolveCredentialAccount 把影子解析回母账号,GetAccessToken / 请求头 / WS 三条路径统一走此函数;影子自身 Credentials 恒为空(仅允许写 model_mapping), 凭据写入的汇聚点 persistAccountCredentials 对影子早返 no-op,防止误写。 - 调度:parentHealthyForShadow 只看母账号是否仍是 OpenAI OAuth + 凭据/传输是否 可用(active、token 未过期、未处于 401/刷新失败/传输故障导致的临时不可调度冷却), 刻意不看母账号的 global 限流窗口——两条 429 道互不连坐。 - 用量:影子的 codex_5h/7d 走 OpenAIQuotaService.QueryUsage(/wham/usage 的 codex_bengalfox),与母账号走的 WSv2 探测(/responses 头)完全独立的数据源、 刷新节流与 staleness 判定。 - 备份:ExportData 显式排除影子账号(影子不持凭据,通用凭据型导入强制 credentials 非空、无法表达父子链接),按 skipped_shadows 计数提示前端。 - 前端:账号操作菜单新增“创建 Spark 影子”入口,影子行展示回填的母账号信息 (邮箱 / plan / 隐私模式 / 订阅到期 / chatgpt_account_id),批量操作自动跳过 影子账号。 说明:migrations 目录用完整文件名(而非纯数字前缀)标识迁移,故本次新增的 154_account_spark_shadow.sql / 154a_..._notx.sql 与已有的 154_add_ops_system_logs_api_key_id.sql 按序号共存,与目录里 145/151 已有的 先例一致。 测试:新增约 20 个测试文件,覆盖 handler(CreateShadow 校验 / 母账号信息回填)、 repository(影子 round-trip / 一母一影唯一索引 / 迁移 schema)、service(凭据 透传三路径 / 调度母健康门 / 用量窗口来源与刷新节流 / CRS 母账号不变量 / 各类 早返与 fail-closed 场景)及前端组件(账号列表 / 操作菜单 / 用量重置)。 验证(镜像 CI;golangci-lint 首次全量分析耗时过长被跳过,其余全部实测): - gofmt -l:干净 - go build ./... / go vet ./...:通过 - go test ./... -count=1:全绿(全部包 ok,含 internal/service、 internal/repository、migrations) - go test -tags integration ./internal/repository/... ./internal/service/... (真实 Postgres,testcontainers):全绿,含迁移幂等性 (TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate)与影子相关全部用例 - pnpm lint:check / pnpm typecheck / pnpm build(真实 vite 构建)/ pnpm vitest run:全绿(124 文件 760 用例) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
483 lines
14 KiB
Go
483 lines
14 KiB
Go
package admin
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// OpenAIOAuthHandler handles OpenAI OAuth-related operations
|
|
type OpenAIOAuthHandler struct {
|
|
openaiOAuthService *service.OpenAIOAuthService
|
|
adminService service.AdminService
|
|
quotaService *service.OpenAIQuotaService
|
|
}
|
|
|
|
func oauthPlatformFromPath(c *gin.Context) string {
|
|
return service.PlatformOpenAI
|
|
}
|
|
|
|
// NewOpenAIOAuthHandler creates a new OpenAI OAuth handler
|
|
func NewOpenAIOAuthHandler(
|
|
openaiOAuthService *service.OpenAIOAuthService,
|
|
adminService service.AdminService,
|
|
quotaService *service.OpenAIQuotaService,
|
|
) *OpenAIOAuthHandler {
|
|
return &OpenAIOAuthHandler{
|
|
openaiOAuthService: openaiOAuthService,
|
|
adminService: adminService,
|
|
quotaService: quotaService,
|
|
}
|
|
}
|
|
|
|
// OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL
|
|
type OpenAIGenerateAuthURLRequest struct {
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
}
|
|
|
|
// GenerateAuthURL generates OpenAI OAuth authorization URL
|
|
// POST /api/v1/admin/openai/generate-auth-url
|
|
func (h *OpenAIOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
|
var req OpenAIGenerateAuthURLRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
// Allow empty body
|
|
req = OpenAIGenerateAuthURLRequest{}
|
|
}
|
|
|
|
result, err := h.openaiOAuthService.GenerateAuthURL(
|
|
c.Request.Context(),
|
|
req.ProxyID,
|
|
req.RedirectURI,
|
|
oauthPlatformFromPath(c),
|
|
)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, result)
|
|
}
|
|
|
|
// OpenAIExchangeCodeRequest represents the request for exchanging OpenAI auth code
|
|
type OpenAIExchangeCodeRequest struct {
|
|
SessionID string `json:"session_id" binding:"required"`
|
|
Code string `json:"code" binding:"required"`
|
|
State string `json:"state" binding:"required"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
}
|
|
|
|
// ExchangeCode exchanges OpenAI authorization code for tokens
|
|
// POST /api/v1/admin/openai/exchange-code
|
|
func (h *OpenAIOAuthHandler) ExchangeCode(c *gin.Context) {
|
|
var req OpenAIExchangeCodeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
|
|
tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{
|
|
SessionID: req.SessionID,
|
|
Code: req.Code,
|
|
State: req.State,
|
|
RedirectURI: req.RedirectURI,
|
|
ProxyID: req.ProxyID,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, tokenInfo)
|
|
}
|
|
|
|
// OpenAIRefreshTokenRequest represents the request for refreshing OpenAI token
|
|
type OpenAIRefreshTokenRequest struct {
|
|
RefreshToken string `json:"refresh_token"`
|
|
RT string `json:"rt"`
|
|
ClientID string `json:"client_id"`
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
}
|
|
|
|
type OpenAICodexPATCreateRequest struct {
|
|
AccessToken string `json:"access_token" binding:"required"`
|
|
Name string `json:"name"`
|
|
Notes *string `json:"notes"`
|
|
GroupIDs []int64 `json:"group_ids"`
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
Concurrency *int `json:"concurrency"`
|
|
Priority *int `json:"priority"`
|
|
RateMultiplier *float64 `json:"rate_multiplier"`
|
|
LoadFactor *int `json:"load_factor"`
|
|
ExpiresAt *int64 `json:"expires_at"`
|
|
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
|
CredentialExtras map[string]any `json:"credential_extras"`
|
|
Extra map[string]any `json:"extra"`
|
|
SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
|
|
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
|
|
}
|
|
|
|
// RefreshToken refreshes an OpenAI OAuth token
|
|
// POST /api/v1/admin/openai/refresh-token
|
|
func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) {
|
|
var req OpenAIRefreshTokenRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
refreshToken := strings.TrimSpace(req.RefreshToken)
|
|
if refreshToken == "" {
|
|
refreshToken = strings.TrimSpace(req.RT)
|
|
}
|
|
if refreshToken == "" {
|
|
response.BadRequest(c, "refresh_token is required")
|
|
return
|
|
}
|
|
|
|
var proxyURL string
|
|
if req.ProxyID != nil {
|
|
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
|
if err == nil && proxy != nil {
|
|
proxyURL = proxy.URL()
|
|
}
|
|
}
|
|
|
|
// 未指定 client_id 时,根据请求路径平台自动设置默认值,避免 repository 层盲猜
|
|
clientID := strings.TrimSpace(req.ClientID)
|
|
if clientID == "" {
|
|
platform := oauthPlatformFromPath(c)
|
|
clientID, _ = openai.OAuthClientConfigByPlatform(platform)
|
|
}
|
|
|
|
tokenInfo, err := h.openaiOAuthService.RefreshTokenWithClientID(c.Request.Context(), refreshToken, proxyURL, clientID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, tokenInfo)
|
|
}
|
|
|
|
// RefreshAccountToken refreshes token for a specific OpenAI account
|
|
// POST /api/v1/admin/openai/accounts/:id/refresh
|
|
func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account ID")
|
|
return
|
|
}
|
|
|
|
// Get account
|
|
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
platform := oauthPlatformFromPath(c)
|
|
if account.Platform != platform {
|
|
response.BadRequest(c, "Account platform does not match OAuth endpoint")
|
|
return
|
|
}
|
|
|
|
// Only refresh OAuth-based accounts
|
|
if !account.IsOAuth() {
|
|
response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
|
|
return
|
|
}
|
|
|
|
// spark 影子账号凭据透传母账号、自身恒空,刷新无意义;在调用上游前早拒,避免先打上游
|
|
// 再被凭据写守卫拦下的无谓副作用(外审第6轮)。
|
|
if account.IsCredentialShadow() {
|
|
response.BadRequest(c, "Cannot refresh spark shadow account; its credentials are managed by the parent account")
|
|
return
|
|
}
|
|
|
|
// Use OpenAI OAuth service to refresh token
|
|
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
// Build new credentials from token info
|
|
newCredentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
|
|
|
// Preserve non-token settings from existing credentials
|
|
for k, v := range account.Credentials {
|
|
if _, exists := newCredentials[k]; !exists {
|
|
newCredentials[k] = v
|
|
}
|
|
}
|
|
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
|
|
|
|
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
|
Credentials: newCredentials,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, dto.AccountFromService(updatedAccount))
|
|
}
|
|
|
|
// CreateAccountFromOAuth creates a new OpenAI OAuth account from token info
|
|
// POST /api/v1/admin/openai/create-from-oauth
|
|
func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
|
var req struct {
|
|
SessionID string `json:"session_id" binding:"required"`
|
|
Code string `json:"code" binding:"required"`
|
|
State string `json:"state" binding:"required"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
ProxyID *int64 `json:"proxy_id"`
|
|
Name string `json:"name"`
|
|
Concurrency int `json:"concurrency"`
|
|
Priority int `json:"priority"`
|
|
GroupIDs []int64 `json:"group_ids"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Exchange code for tokens
|
|
tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{
|
|
SessionID: req.SessionID,
|
|
Code: req.Code,
|
|
State: req.State,
|
|
RedirectURI: req.RedirectURI,
|
|
ProxyID: req.ProxyID,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
// Build credentials from token info
|
|
credentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
|
|
|
platform := oauthPlatformFromPath(c)
|
|
|
|
// Use email as default name if not provided
|
|
name := req.Name
|
|
if name == "" && tokenInfo.Email != "" {
|
|
name = tokenInfo.Email
|
|
}
|
|
if name == "" {
|
|
name = "OpenAI OAuth Account"
|
|
}
|
|
|
|
// Create account
|
|
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
|
Name: name,
|
|
Platform: platform,
|
|
Type: "oauth",
|
|
Credentials: credentials,
|
|
Extra: nil,
|
|
ProxyID: req.ProxyID,
|
|
Concurrency: req.Concurrency,
|
|
Priority: req.Priority,
|
|
GroupIDs: req.GroupIDs,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, dto.AccountFromService(account))
|
|
}
|
|
|
|
// CreateAccountFromCodexPAT creates an OpenAI OAuth account from a Codex at-* personal access token.
|
|
// POST /api/v1/admin/openai/create-from-codex-pat
|
|
func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) {
|
|
var req OpenAICodexPATCreateRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
if req.Concurrency != nil && *req.Concurrency < 0 {
|
|
response.BadRequest(c, "concurrency must be >= 0")
|
|
return
|
|
}
|
|
if req.Priority != nil && *req.Priority < 0 {
|
|
response.BadRequest(c, "priority must be >= 0")
|
|
return
|
|
}
|
|
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
|
|
response.BadRequest(c, "rate_multiplier must be >= 0")
|
|
return
|
|
}
|
|
if req.LoadFactor != nil && *req.LoadFactor > 10000 {
|
|
response.BadRequest(c, "load_factor must be <= 10000")
|
|
return
|
|
}
|
|
|
|
var proxyURL string
|
|
if req.ProxyID != nil {
|
|
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
if proxy != nil {
|
|
proxyURL = proxy.URL()
|
|
}
|
|
}
|
|
|
|
tokenInfo, err := h.openaiOAuthService.ValidateCodexPersonalAccessToken(c.Request.Context(), req.AccessToken, proxyURL)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
credentials := mergeCodexImportMap(
|
|
h.openaiOAuthService.BuildAccountCredentials(tokenInfo),
|
|
sanitizeCodexImportCredentialExtras(req.CredentialExtras),
|
|
)
|
|
extra := mergeCodexImportMap(req.Extra, map[string]any{
|
|
"import_source": "codex_personal_access_token",
|
|
"auth_provider": "codex_personal_access_token",
|
|
"imported_at": time.Now().UTC().Format(time.RFC3339),
|
|
"access_token_sha256": codexTokenFingerprint(req.AccessToken),
|
|
})
|
|
|
|
concurrency := 3
|
|
if req.Concurrency != nil {
|
|
concurrency = *req.Concurrency
|
|
}
|
|
priority := 50
|
|
if req.Priority != nil {
|
|
priority = *req.Priority
|
|
}
|
|
skipDefaultGroupBind := false
|
|
if req.SkipDefaultGroupBind != nil {
|
|
skipDefaultGroupBind = *req.SkipDefaultGroupBind
|
|
}
|
|
|
|
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
|
Name: buildOpenAICodexPATAccountName(req.Name, tokenInfo),
|
|
Notes: req.Notes,
|
|
Platform: service.PlatformOpenAI,
|
|
Type: service.AccountTypeOAuth,
|
|
Credentials: credentials,
|
|
Extra: extra,
|
|
ProxyID: req.ProxyID,
|
|
Concurrency: concurrency,
|
|
Priority: priority,
|
|
RateMultiplier: req.RateMultiplier,
|
|
LoadFactor: req.LoadFactor,
|
|
GroupIDs: req.GroupIDs,
|
|
ExpiresAt: req.ExpiresAt,
|
|
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
|
SkipDefaultGroupBind: skipDefaultGroupBind,
|
|
SkipMixedChannelCheck: req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, dto.AccountFromService(account))
|
|
}
|
|
|
|
func buildOpenAICodexPATAccountName(name string, tokenInfo *service.OpenAITokenInfo) string {
|
|
name = strings.TrimSpace(name)
|
|
if name != "" {
|
|
return name
|
|
}
|
|
if tokenInfo != nil {
|
|
for _, candidate := range []string{tokenInfo.Email, tokenInfo.ChatGPTAccountID, tokenInfo.ChatGPTUserID} {
|
|
if candidate = strings.TrimSpace(candidate); candidate != "" {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
return "Codex PAT Account"
|
|
}
|
|
|
|
// QueryQuota queries the rate-limit / quota usage for an OpenAI account.
|
|
// GET /api/v1/admin/openai/accounts/:id/quota
|
|
func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account ID")
|
|
return
|
|
}
|
|
if h.quotaService == nil {
|
|
response.BadRequest(c, "openai quota service is not enabled")
|
|
return
|
|
}
|
|
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, usage)
|
|
}
|
|
|
|
// CreateShadowRequest is the request body for CreateShadow.
|
|
type CreateShadowRequest struct {
|
|
Name string `json:"name"`
|
|
Priority int `json:"priority"`
|
|
Concurrency int `json:"concurrency"`
|
|
GroupIDs []int64 `json:"group_ids"`
|
|
}
|
|
|
|
// CreateShadow creates a spark-dimension shadow account for a parent OpenAI OAuth account.
|
|
// POST /api/v1/admin/accounts/:id/shadow
|
|
func (h *OpenAIOAuthHandler) CreateShadow(c *gin.Context) {
|
|
parentID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account ID")
|
|
return
|
|
}
|
|
|
|
var req CreateShadowRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
|
|
shadow, err := h.adminService.CreateShadow(c.Request.Context(), parentID, service.ShadowOptions{
|
|
Name: req.Name,
|
|
Priority: req.Priority,
|
|
Concurrency: req.Concurrency,
|
|
GroupIDs: req.GroupIDs,
|
|
})
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, dto.AccountFromServiceShallow(shadow))
|
|
}
|
|
|
|
// ResetQuota consumes one rate-limit reset credit for an OpenAI account.
|
|
// POST /api/v1/admin/openai/accounts/:id/reset-quota
|
|
func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
|
|
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.BadRequest(c, "Invalid account ID")
|
|
return
|
|
}
|
|
if h.quotaService == nil {
|
|
response.BadRequest(c, "openai quota service is not enabled")
|
|
return
|
|
}
|
|
result, err := h.quotaService.ResetCredit(c.Request.Context(), accountID)
|
|
if err != nil {
|
|
response.ErrorFrom(c, err)
|
|
return
|
|
}
|
|
response.Success(c, result)
|
|
}
|