feat(spark-shadow): OpenAI Spark 链接型影子账号

背景: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>
This commit is contained in:
DaydreamCoding
2026-07-01 12:21:45 +08:00
committed by QTom
co-authored by Claude Sonnet 5
parent 5f62625ae6
commit bdf7ead157
95 changed files with 6388 additions and 179 deletions
+29 -3
View File
@@ -30,6 +30,9 @@ type DataPayload struct {
ExportedAt string `json:"exported_at"`
Proxies []DataProxy `json:"proxies"`
Accounts []DataAccount `json:"accounts"`
// SkippedShadows 记录导出时被排除的 spark 影子账号数量(见 ExportData)。仅作可见性提示,
// 导入侧忽略该字段;omitempty 保持向后兼容。
SkippedShadows int `json:"skipped_shadows,omitempty"`
}
type DataProxy struct {
@@ -50,6 +53,10 @@ type DataProxy struct {
// DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径,
// Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本,
// 应新增独立结构而非修改这里。
// 注意:本结构不含 parent_account_id/quota_dimension——spark 影子账号在 ExportData 处被显式
// 排除(影子不持凭据、通用凭据型导入强制 credentials 非空无法重建父子链接),不在此表达。
// 影子的独立调度配置(priority/并发/分组/status 管理员可单独调)亦不在本备份范围,属已知局限
// (外审第6轮裁决:保持排除 + 前端警告,而非升级格式做完整往返)。
type DataAccount struct {
Name string `json:"name"`
Notes *string `json:"notes,omitempty"`
@@ -105,6 +112,24 @@ func (h *AccountHandler) ExportData(c *gin.Context) {
return
}
// 排除 spark 影子账号:影子不持凭据,通用凭据型导出无法表达父子链接、导入侧又强制 credentials
// 非空——若混入会产出无法还原的坏备份(导入即失败)。影子的独立调度配置(priority/并发/分组/
// status,管理员可单独调)随之不进备份,还原后需在重建的影子上重新调优;前端按 skipped_shadows
// 提示用户(外审第5轮发现、第6轮裁决:保持排除 + 警告,不做完整往返)。
skippedShadows := 0
exportable := make([]service.Account, 0, len(accounts))
for i := range accounts {
if accounts[i].IsCredentialShadow() {
skippedShadows++
continue
}
exportable = append(exportable, accounts[i])
}
accounts = exportable
if skippedShadows > 0 {
slog.Info("export_skipped_spark_shadows", "count", skippedShadows)
}
includeProxies, err := parseIncludeProxies(c)
if err != nil {
response.BadRequest(c, err.Error())
@@ -191,9 +216,10 @@ func (h *AccountHandler) ExportData(c *gin.Context) {
}
payload := DataPayload{
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Proxies: dataProxies,
Accounts: dataAccounts,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Proxies: dataProxies,
Accounts: dataAccounts,
SkippedShadows: skippedShadows,
}
response.Success(c, payload)
@@ -18,10 +18,11 @@ type dataResponse struct {
}
type dataPayload struct {
Type string `json:"type"`
Version int `json:"version"`
Proxies []dataProxy `json:"proxies"`
Accounts []dataAccount `json:"accounts"`
Type string `json:"type"`
Version int `json:"version"`
Proxies []dataProxy `json:"proxies"`
Accounts []dataAccount `json:"accounts"`
SkippedShadows int `json:"skipped_shadows"`
}
type dataProxy struct {
@@ -172,6 +173,46 @@ func TestExportDataWithoutProxies(t *testing.T) {
require.Nil(t, resp.Data.Accounts[0].ProxyKey)
}
// TestExportDataExcludesSparkShadow 验证外审第5轮 P1/P2:导出时排除 spark 影子账号
// (影子无凭据、导入侧强制 credentials 非空,混入会产出无法还原的坏备份),并透出跳过计数。
func TestExportDataExcludesSparkShadow(t *testing.T) {
router, adminSvc := setupAccountDataRouter()
parentID := int64(21)
adminSvc.accounts = []service.Account{
{
ID: parentID,
Name: "mother",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{"token": "secret"},
Status: service.StatusActive,
},
{
ID: 22,
Name: "mother (Spark)",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{}, // 影子恒空凭据
ParentAccountID: &parentID, // 影子标记
QuotaDimension: service.QuotaDimensionSpark,
Status: service.StatusActive,
},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data?include_proxies=false", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp dataResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Len(t, resp.Data.Accounts, 1, "影子应被排除,仅导出母账号")
require.Equal(t, "mother", resp.Data.Accounts[0].Name)
require.Equal(t, 1, resp.Data.SkippedShadows, "跳过的影子数量应透出")
}
func TestExportDataPassesAccountFiltersAndSort(t *testing.T) {
router, adminSvc := setupAccountDataRouter()
adminSvc.accounts = []service.Account{
@@ -221,6 +221,8 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac
}
}
h.enrichShadowParents(ctx, []AccountWithConcurrency{item})
return item
}
@@ -382,6 +384,8 @@ func (h *AccountHandler) List(c *gin.Context) {
result[i] = item
}
h.enrichShadowParents(c.Request.Context(), result)
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
if etag != "" {
c.Header("ETag", etag)
@@ -834,6 +838,12 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
if !account.IsOAuth() {
return nil, "", infraerrors.BadRequest("NOT_OAUTH", "cannot refresh non-OAuth account")
}
// spark 影子凭据由母账号管理、自身恒空,刷新无意义且会先打上游;在调用上游前早拒
// (覆盖单账号与批量两入口;批量侧将其计为 failed 并附说明)(外审第6轮)。
if account.IsCredentialShadow() {
return nil, "", infraerrors.BadRequest("SPARK_SHADOW_NO_REFRESH",
"cannot refresh spark shadow account; its credentials are managed by the parent account")
}
var newCredentials map[string]any
@@ -1814,7 +1824,7 @@ func (h *AccountHandler) ResetQuota(c *gin.Context) {
}
if err := h.adminService.ResetAccountQuota(c.Request.Context(), accountID); err != nil {
response.InternalError(c, "Failed to reset account quota: "+err.Error())
response.ErrorFrom(c, err)
return
}
@@ -213,6 +213,48 @@ func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefau
require.NotEqual(t, "gpt-5", resp.Data[0].ID)
}
func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(t *testing.T) {
parentID := int64(100)
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 44,
Name: "openai-spark-shadow",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
ParentAccountID: &parentID,
QuotaDimension: service.QuotaDimensionSpark,
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
},
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
ids := make([]string, 0, len(resp.Data))
for _, m := range resp.Data {
ids = append(ids, m.ID)
}
require.ElementsMatch(t, []string{
"gpt-5.3-codex-spark",
}, ids, "影子可用模型由 model_mapping 派生(非写死)")
}
func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -0,0 +1,29 @@
package admin
import (
"context"
"net/http"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// TestRefreshSingleAccount_RejectsShadow 验证外审第6轮:手动刷新对 spark 影子在调用上游前早拒
// (影子凭据由母账号管理、自身恒空,刷新无意义)。该守卫同时覆盖单账号与批量刷新两入口。
func TestRefreshSingleAccount_RejectsShadow(t *testing.T) {
h := &AccountHandler{} // 影子在使用任何依赖前即返回,无需注入
parentID := int64(5)
shadow := &service.Account{
ID: 9,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth, // IsOAuth()=true,确保不是先撞 NOT_OAUTH
ParentAccountID: &parentID,
QuotaDimension: service.QuotaDimensionSpark,
}
_, _, err := h.refreshSingleAccount(context.Background(), shadow)
require.Error(t, err, "影子刷新应被早拒")
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
}
@@ -0,0 +1,56 @@
package admin
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// enrichShadowParentInfo 把母账号的展示信息回填到影子行的 parent_* 字段。
// 纯函数:仅依赖传入的母账号 map,便于单测;非影子或母账号缺失时优雅留空。
func enrichShadowParentInfo(items []AccountWithConcurrency, parents map[int64]*service.Account) {
for i := range items {
a := items[i].Account
if a == nil || a.ParentAccountID == nil {
continue
}
p := parents[*a.ParentAccountID]
if p == nil {
continue
}
a.ParentEmail = p.GetCredential("email")
a.ParentPlanType = p.GetCredential("plan_type")
a.ParentSubscriptionExpiresAt = p.GetCredential("subscription_expires_at")
a.ParentChatGPTAccountID = p.GetCredential("chatgpt_account_id")
a.ParentPrivacyMode = p.GetExtraString("privacy_mode")
}
}
// enrichShadowParents 收集本批影子行的母账号 ID、一次批量解析(避免 N+1),再回填。
// 解析失败时不报错(parent_* 留空,降级)。
func (h *AccountHandler) enrichShadowParents(ctx context.Context, items []AccountWithConcurrency) {
seen := make(map[int64]struct{})
for i := range items {
a := items[i].Account
if a == nil || a.ParentAccountID == nil {
continue
}
seen[*a.ParentAccountID] = struct{}{}
}
if len(seen) == 0 {
return
}
parentIDs := make([]int64, 0, len(seen))
for pid := range seen {
parentIDs = append(parentIDs, pid)
}
parents, err := h.adminService.GetAccountsByIDs(ctx, parentIDs)
if err != nil {
return
}
pmap := make(map[int64]*service.Account, len(parents))
for _, p := range parents {
pmap[p.ID] = p
}
enrichShadowParentInfo(items, pmap)
}
@@ -0,0 +1,42 @@
package admin
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestEnrichShadowParentInfo(t *testing.T) {
pid := int64(100)
parent := &service.Account{
ID: 100,
Credentials: map[string]any{
"email": "owner@example.com",
"plan_type": "pro",
"subscription_expires_at": "2026-12-31T00:00:00Z",
"chatgpt_account_id": "acct_123",
},
Extra: map[string]any{"privacy_mode": "training_off"},
}
parents := map[int64]*service.Account{100: parent}
shadow := AccountWithConcurrency{Account: &dto.Account{ID: 200, ParentAccountID: &pid}}
normal := AccountWithConcurrency{Account: &dto.Account{ID: 1}}
orphan := AccountWithConcurrency{Account: &dto.Account{ID: 201, ParentAccountID: ptrInt64(999)}}
items := []AccountWithConcurrency{shadow, normal, orphan}
enrichShadowParentInfo(items, parents)
require.Equal(t, "owner@example.com", items[0].ParentEmail, "影子回填母账号邮箱")
require.Equal(t, "pro", items[0].ParentPlanType)
require.Equal(t, "training_off", items[0].ParentPrivacyMode)
require.Equal(t, "2026-12-31T00:00:00Z", items[0].ParentSubscriptionExpiresAt)
require.Equal(t, "acct_123", items[0].ParentChatGPTAccountID)
require.Empty(t, items[1].ParentEmail, "非影子不回填")
require.Empty(t, items[2].ParentEmail, "母账号缺失时优雅留空")
}
func ptrInt64(v int64) *int64 { return &v }
@@ -26,6 +26,7 @@ type stubAdminService struct {
testedProxyIDs []int64
getUserErr error
createAccountErr error
createSparkShadowErr error
updateAccountErr error
bulkUpdateAccountErr error
checkMixedErr error
@@ -636,5 +637,25 @@ func (s *stubAdminService) RevertAccountProxyFallback(ctx context.Context, id in
return nil
}
func (s *stubAdminService) CreateShadow(ctx context.Context, parentID int64, opts service.ShadowOptions) (*service.Account, error) {
if s.createSparkShadowErr != nil {
return nil, s.createSparkShadowErr
}
pid := parentID
return &service.Account{
ID: 9001,
Name: opts.Name,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Priority: opts.Priority,
Concurrency: opts.Concurrency,
GroupIDs: opts.GroupIDs,
ParentAccountID: &pid,
QuotaDimension: service.QuotaDimensionSpark,
Credentials: map[string]any{},
Extra: map[string]any{},
}, nil
}
// Ensure stub implements interface.
var _ service.AdminService = (*stubAdminService)(nil)
@@ -194,6 +194,13 @@ func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
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 {
@@ -417,6 +424,43 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
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) {
@@ -0,0 +1,106 @@
//go:build unit
package admin
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &stubAdminService{}
h := NewOpenAIOAuthHandler(nil, stub, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
body := `{"name":"p-spark","priority":50,"concurrency":2,"group_ids":[10,20]}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
data, ok := resp["data"].(map[string]any)
require.True(t, ok, "response should have data field")
// parent_account_id must be present and equal to the path param
pid, ok := data["parent_account_id"].(float64)
require.True(t, ok, "parent_account_id should be present")
require.Equal(t, float64(42), pid)
// quota_dimension must be "spark"
require.Equal(t, service.QuotaDimensionSpark, data["quota_dimension"])
// name round-trips
require.Equal(t, "p-spark", data["name"])
}
func TestCreateShadow_InvalidID(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/not-a-number/shadow",
strings.NewReader(`{"name":"x"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestCreateShadow_ServiceError(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")}
h := NewOpenAIOAuthHandler(nil, stub, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
body := `{"name":"p-spark","priority":50}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
// A generic (non-ApplicationError) service error maps to 500 via response.ErrorFrom.
require.GreaterOrEqual(t, rec.Code, http.StatusBadRequest)
require.Equal(t, http.StatusInternalServerError, rec.Code)
}
func TestCreateShadow_BadBody(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow",
strings.NewReader(`{not valid json`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
+2
View File
@@ -234,6 +234,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
SessionWindowEnd: a.SessionWindowEnd,
SessionWindowStatus: a.SessionWindowStatus,
GroupIDs: a.GroupIDs,
ParentAccountID: a.ParentAccountID,
QuotaDimension: a.QuotaDimension,
}
// 提取 5h 窗口费用控制和会话数量控制配置(仅 Anthropic OAuth/SetupToken 账号有效)
+11
View File
@@ -253,6 +253,17 @@ type Account struct {
QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"`
QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"`
// 影子账号关系(spark 维度影子)
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
QuotaDimension string `json:"quota_dimension,omitempty"`
// 影子账号回填的母账号信息(仅影子非空,源自母账号 Credentials/Extra)
ParentEmail string `json:"parent_email,omitempty"`
ParentPlanType string `json:"parent_plan_type,omitempty"`
ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"`
ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"`
ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"`
Proxy *Proxy `json:"proxy,omitempty"`
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
@@ -509,7 +509,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
}
}
if result != nil {
if account.Type == service.AccountTypeOAuth {
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
@@ -1535,7 +1536,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
if result == nil {
return
}
if account.Type == service.AccountTypeOAuth {
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders)
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
+2 -1
View File
@@ -325,7 +325,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
}
}
if result != nil {
if account.Type == service.AccountTypeOAuth {
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)