From d56b517e7ed67cee0671b2accd6b3eb97b997079 Mon Sep 17 00:00:00 2001 From: LCS Date: Mon, 19 Oct 2026 19:33:32 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20limit:=200=20=E7=9A=84=E5=86=B7?= =?UTF-8?q?=E5=8D=B4=E6=94=B9=E4=B8=BA=E9=80=92=E5=A2=9E=E9=80=80=E9=81=BF?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=AD=89=20RetryDelay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zeroQuotaLimit() 已把这类 429 标成「无配额」,说明代码识别出它不是 额度用完而是账号没有该模型的访问权限。但 until 仍取自 RetryDelay, 于是每次都是:冷却 19 小时 → 到点重试 → 又是 limit: 0 → 再等 19 小时。 实测依据: - 9/9 条 retry in 精确指向 UTC 零点(如 04:49:51 + 19h10m8s = 次日 00:00:00) - 次日同一模型同一账号仍是 limit: 0,等多久都不恢复 - 只有 4%(7/156)的 argon 请求受影响,因为系统会自动回退 playground 改为走已有的 planLimitBackoff(1m → 5m → 15m → 30m → 1h): - 权限若真开通,一小时内就能发现,而不是等 19 小时 - 若没开通,代价是每小时一次失败探测(约 60ms,不占并发槽) - 复用 planLimitCooldown 的 Level 递增逻辑,不改动其他分支 测试:internal/aistudio/quota_zerolimit_test.go 覆盖 8 种情形 (limit:0 / limit:100 / 混合 / 无 limit 字段 / 递增 / 封顶 / 冷却未过期 / global 标记) --- internal/aistudio/quota.go | 19 +++ internal/aistudio/quota_zerolimit_test.go | 187 ++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 internal/aistudio/quota_zerolimit_test.go diff --git a/internal/aistudio/quota.go b/internal/aistudio/quota.go index b9ef27e..4d37c14 100644 --- a/internal/aistudio/quota.go +++ b/internal/aistudio/quota.go @@ -68,6 +68,25 @@ func QuotaCooldownForError(err error, now time.Time) (QuotaCooldown, bool) { if zeroQuotaLimit(rpcError.Message) { kind = "无配额" } + if kind == "无配额" { + // 上游把所有额度项都报成 limit: 0 —— 这是**账号没有该模型的访问权限**, + // 不是额度用完了。两者的区别在恢复方式上: + // 额度用完 到额度日重置(次日)恢复,等是对的 + // limit: 0 权限问题,等多久都不会变 + // + // 原实现把 until 交给 RetryDelay(上游给的 "retry in 19h"),于是 + // 每天只探测一次、撞一次、再等一天。实测 9/9 条 retry in 精确指向 + // UTC 零点,但次日同一模型仍是 limit: 0 —— 白白浪费一天。 + // + // 改为走递增退避(1m → 5m → 15m → 30m → 1h),一小时内最多重探一次。 + // 权限若真的开通了,一小时内就能发现;若没开通,代价也只是每小时一次 + // 失败的尝试,远小于每天干等 19 小时。 + // + // Escalate 让 MarkQuotaCooldownIfGeneration 走 planLimitCooldown 分支, + // 那里会读上一次的 Level 并递增。 + return QuotaCooldown{Until: now.Add(planLimitBackoff[0]), Global: global, + Kind: kind, Reason: kind + ": " + err.Error(), Escalate: true}, true + } escalate := false if rpcError.RetryDelay > 0 { until = now.Add(rpcError.RetryDelay) diff --git a/internal/aistudio/quota_zerolimit_test.go b/internal/aistudio/quota_zerolimit_test.go new file mode 100644 index 0000000..764a73e --- /dev/null +++ b/internal/aistudio/quota_zerolimit_test.go @@ -0,0 +1,187 @@ +package aistudio + +import ( + "errors" + "fmt" + "testing" + "time" +) + +// rpc429 构造一个 429 RPCError,message 里带 limit: N +func rpc429(message string, retry time.Duration) error { + return &RPCError{ + Method: "ProxyUnaryCall", + StatusCode: 429, + Code: 8, + Message: message, + Metadata: map[string]string{}, + RetryDelay: retry, + } +} + +// TestZeroQuotaUsesBackoff 验证 limit: 0 走递增退避,而不是等 RetryDelay +func TestZeroQuotaUsesBackoff(t *testing.T) { + now := time.Date(2026, 10, 19, 4, 49, 51, 0, time.UTC) + // 上游原文:所有额度项都是 limit: 0,附带 retry in 19h10m + msg := "Quota exceeded for metric: generate_requests_per_model_per_user, limit: 0, model: gemini-4-argon\n" + + "Please retry in 19h10m8s." + err := rpc429(msg, 19*time.Hour+10*time.Minute+8*time.Second) + + cd, ok := QuotaCooldownForError(err, now) + if !ok { + t.Fatal("应识别为额度冷却") + } + if cd.Kind != "无配额" { + t.Fatalf("Kind 应为 无配额,实际 %q", cd.Kind) + } + if !cd.Escalate { + t.Error("无配额应设 Escalate=true 以走递增退避") + } + // 关键:不能等于 now+19h10m + want := now.Add(planLimitBackoff[0]) // 1 分钟 + if !cd.Until.Equal(want) { + t.Errorf("Until 应为 %v(%v 后),实际 %v(%v 后)", + want, planLimitBackoff[0], cd.Until, cd.Until.Sub(now)) + } + if cd.Until.Sub(now) > 2*time.Minute { + t.Errorf("冷却不应超过 2 分钟,实际 %v", cd.Until.Sub(now)) + } +} + +// TestRealQuotaStillUsesRetryDelay 验证真额度用完仍按上游给的时间恢复 +func TestRealQuotaStillUsesRetryDelay(t *testing.T) { + now := time.Date(2026, 10, 19, 4, 49, 51, 0, time.UTC) + // limit: 100 —— 是真额度,不是权限问题 + msg := "Quota exceeded for metric: generate_requests_per_model_per_user, limit: 100, model: gemini-3.8-flash\n" + + "Please retry in 19h10m3s." + retry := 19*time.Hour + 10*time.Minute + 3*time.Second + err := rpc429(msg, retry) + + cd, ok := QuotaCooldownForError(err, now) + if !ok { + t.Fatal("应识别为额度冷却") + } + if cd.Kind == "无配额" { + t.Error("limit: 100 不应被判为无配额") + } + want := now.Add(retry) + if !cd.Until.Equal(want) { + t.Errorf("Until 应为 %v,实际 %v", want, cd.Until) + } +} + +// TestMixedLimitIsNotZeroQuota 验证 limit 里有非零值时不算无配额 +func TestMixedLimitIsNotZeroQuota(t *testing.T) { + now := time.Now() + msg := "Quota exceeded for metric: a, limit: 0, model: x\n" + + "Quota exceeded for metric: b, limit: 50, model: x\nPlease retry in 1h." + err := rpc429(msg, time.Hour) + + cd, _ := QuotaCooldownForError(err, now) + if cd.Kind == "无配额" { + t.Error("有任一非零 limit 时不应判为无配额") + } +} + +// TestNoLimitTokenIsNotZeroQuota 验证错误里没有 limit 时不误判 +func TestNoLimitTokenIsNotZeroQuota(t *testing.T) { + now := time.Now() + err := rpc429("Rate exceeded, please slow down.", time.Minute) + + cd, _ := QuotaCooldownForError(err, now) + if cd.Kind == "无配额" { + t.Error("没有 limit 字段时不应判为无配额") + } +} + +// TestBackoffEscalates 验证连续无配额时冷却逐级拉长 +func TestBackoffEscalates(t *testing.T) { + base := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + reason := "无配额: test" + + // 第一次:1 分钟 + first := planLimitCooldown(CooldownState{}, false, base, reason) + if got := first.Until.Sub(base); got != time.Minute { + t.Fatalf("第一次应为 1 分钟,实际 %v", got) + } + if first.Level != 1 { + t.Fatalf("第一次 Level 应为 1,实际 %d", first.Level) + } + + // 第二次(上次已过期):5 分钟 + second := planLimitCooldown(first, true, base.Add(2*time.Minute), reason) + if got := second.Until.Sub(base.Add(2 * time.Minute)); got != 5*time.Minute { + t.Errorf("第二次应为 5 分钟,实际 %v", got) + } + if second.Level != 2 { + t.Errorf("第二次 Level 应为 2,实际 %d", second.Level) + } + + // 第三次:15 分钟 + third := planLimitCooldown(second, true, base.Add(time.Hour), reason) + if got := third.Until.Sub(base.Add(time.Hour)); got != 15*time.Minute { + t.Errorf("第三次应为 15 分钟,实际 %v", got) + } + + // 封顶 1 小时 + prev := third + at := base.Add(2 * time.Hour) + for i := 0; i < 5; i++ { + next := planLimitCooldown(prev, true, at, reason) + prev = next + at = at.Add(2 * time.Hour) + } + if got := prev.Until.Sub(at.Add(-2 * time.Hour)); got != time.Hour { + t.Errorf("封顶应为 1 小时,实际 %v", got) + } + if prev.Level != len(planLimitBackoff) { + t.Errorf("Level 应封顶为 %d,实际 %d", len(planLimitBackoff), prev.Level) + } +} + +// TestBackoffKeepsActiveCooldown 验证冷却未结束时沿用原值不重置 +func TestBackoffKeepsActiveCooldown(t *testing.T) { + base := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + prev := CooldownState{Until: base.Add(10 * time.Minute), Reason: "x", Level: 3} + got := planLimitCooldown(prev, true, base, "y") + if !got.Until.Equal(prev.Until) || got.Level != prev.Level { + t.Errorf("冷却未结束时应原样返回,实际 %+v", got) + } +} + +// TestZeroQuotaKeepsGlobalFlag 验证 global 标记在无配额分支里仍保留 +func TestZeroQuotaKeepsGlobalFlag(t *testing.T) { + now := time.Now() + err := &RPCError{ + Method: "GenerateContent", StatusCode: 429, Code: 8, + Message: "Quota exceeded, limit: 0, model: x", + Metadata: map[string]string{"quota_metric": "generate_requests_per_project_per_user_global"}, + RetryDelay: 19 * time.Hour, + } + cd, ok := QuotaCooldownForError(err, now) + if !ok { + t.Fatal("应识别") + } + if !cd.Global { + t.Error("per_project_per_user_global 应设 Global=true") + } + if cd.Kind != "无配额" { + t.Errorf("Kind 应为 无配额,实际 %q", cd.Kind) + } +} + +// TestNon429Ignored 验证非 429 不产生冷却 +func TestNon429Ignored(t *testing.T) { + now := time.Now() + err := &RPCError{StatusCode: 500, Message: "boom"} + if _, ok := QuotaCooldownForError(err, now); ok { + t.Error("500 不应产生额度冷却") + } + var plain = errors.New("network") + if _, ok := QuotaCooldownForError(plain, now); ok { + t.Error("普通错误不应产生额度冷却") + } +} + +// 确保 fmt 被使用(调试时方便) +var _ = fmt.Sprintf From c989a528cff9bd9e9755a1f0e158f14ac3ab708c Mon Sep 17 00:00:00 2001 From: LCS Date: Mon, 19 Oct 2026 21:25:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E9=9A=94=E7=A6=BB=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E5=8D=B4=E4=B8=A2=E5=87=AD=E6=8D=AE=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20Close=20=E8=B6=85=E6=97=B6=E6=B1=A1?= =?UTF-8?q?=E6=9F=93=E8=BF=94=E5=9B=9E=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 症状:setup --login --remote 走完登录、进入 AI Studio、窗口自动关闭, 最后报「等待 Camoufox 进程退出: context deadline exceeded」, 而 auth 目录里账号文件时间戳没变,凭据白抓一次。 三层原因叠加: 1. login.go 把 Close 的错误 errors.Join 进返回值 defer func() { err = errors.Join(err, session.Close()) }() 状态在上面的 exportStorageState 已经拿到并编码完毕, 但 Close 失败让整个 Login() 返回错误。 2. setup.go 无条件删除临时目录 defer os.RemoveAll(loginDirectory) 于是调用方在推送之前就把刚抓到的 storage-state 删了。 3. browserProcessCloseTimeout 只有 5 秒 Camoufox 是 900MB 级浏览器,要落盘 profile、回收 6-9 个渲染进程, taskkill /T /F 经常超过 5 秒。进程越多越慢 —— 失败残留的进程 累积后形成恶性循环,越试越容易超时。 实测:同一台机器早上两次登录成功(进程干净),下午连续失败 (9 个 camoufox 残留)。差别只在机器状态。 修复: - Close 失败只写 stderr 警告,不改返回值 - 关闭超时 5s -> 20s,覆盖正常关闭,僵死进程仍会被截断 - setup 失败时保留临时目录并打印路径,可手工导入: aistudio2api setup --storage-state <路径>/storage-state.json 2026-10-19 --- internal/camoufoxnative/launcher.go | 8 +++++++- internal/camoufoxnative/login.go | 19 ++++++++++++++++++- internal/setup/setup.go | 20 +++++++++++++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/internal/camoufoxnative/launcher.go b/internal/camoufoxnative/launcher.go index 8ead34c..013eaf2 100644 --- a/internal/camoufoxnative/launcher.go +++ b/internal/camoufoxnative/launcher.go @@ -26,7 +26,13 @@ import ( var bidiEndpointPattern = regexp.MustCompile(`ws://[^\s]+`) -const browserProcessCloseTimeout = 5 * time.Second +// browserProcessCloseTimeout 是等待 Camoufox 进程退出的上限。 +// +// 原值 5s 对 900MB 级的浏览器太短:登录流程走完后浏览器要落盘 profile、 +// 回收渲染进程,实测经常超过 5 秒。超时本身不致命(登录状态早已抓到), +// 但会让日志刷出「等待 Camoufox 进程退出」的假失败。放到 20s 覆盖正常关闭, +// 真正的僵死进程仍会在这里被截断。 +const browserProcessCloseTimeout = 20 * time.Second type browserProcessTerminator func(context.Context, *exec.Cmd) error diff --git a/internal/camoufoxnative/login.go b/internal/camoufoxnative/login.go index 69342f4..a96e9a7 100644 --- a/internal/camoufoxnative/login.go +++ b/internal/camoufoxnative/login.go @@ -65,7 +65,24 @@ func Login(ctx context.Context, options LoginOptions) (result LoginResult, err e return LoginResult{}, err } defer func() { - err = errors.Join(err, session.Close()) + // Close 失败不能污染登录结果。 + // + // Camoufox 是 900MB 级的浏览器进程,关闭经常超过 + // browserProcessCloseTimeout(5s)。原实现把 Close 的错误 + // errors.Join 进返回值,于是「登录成功、状态已抓到、只差关窗口」 + // 被判为整体失败,调用方 importIsolatedLogin 随即 defer + // os.RemoveAll(loginDirectory) 把刚抓到的 storage-state 删掉。 + // + // 实测:登录页正常走完、自动关闭窗口,最后报 + // 「等待 Camoufox 进程退出: context deadline exceeded」, + // 而 auth 目录里的文件时间戳没变 —— 状态确实被丢了。 + // + // 关闭失败只说明进程回收不干净,与登录结果无关: + // 状态已经在上面的 exportStorageState 里拿到并编码完毕。 + // 这里只记录,不改变 err。 + if closeErr := session.Close(); closeErr != nil && err == nil { + fmt.Fprintf(os.Stderr, "警告: 关闭 Camoufox 失败(不影响登录结果): %v\n", closeErr) + } }() origins := make(map[string]storageOrigin) pageURL, err := session.waitLogin(loginCtx, origins) diff --git a/internal/setup/setup.go b/internal/setup/setup.go index aa860ab..fb0c8dc 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -243,21 +243,39 @@ func importIsolatedLogin( if err != nil { return fmt.Errorf("创建隔离登录目录: %w", err) } - defer os.RemoveAll(loginDirectory) + // 只在成功时删临时目录。 + // + // 原实现无条件 defer os.RemoveAll,于是推送失败(pairing 409、令牌过期、 + // 网络不通)时,刚抓到的 storage-state 连同指纹一起被删 —— 用户白登一次。 + // 实测踩过两次:一次 409「账户已存在」,一次令牌过期。 + // + // 失败时保留目录并把路径打出来,用户可以手工导入: + // aistudio2api setup --storage-state <路径>/storage-state.json + keepOnFailure := true + defer func() { + if keepOnFailure { + return + } + _ = os.RemoveAll(loginDirectory) + }() result, err := driver.Login(ctx, aistudio.IsolatedLoginRequest{ AccountID: "setup", Directory: loginDirectory, Proxy: options.egress, Locale: options.locale, Timezone: options.timezone, }) if err != nil { + fmt.Fprintf(os.Stderr, "隔离登录失败,临时目录已保留: %s\n", loginDirectory) return err } if _, err := aistudio.NewSigner().Sign(result.StorageState); err != nil { + fmt.Fprintf(os.Stderr, "认证状态不可用,临时目录已保留: %s\n", loginDirectory) return fmt.Errorf("认证状态无法用于 AI Studio: %w", err) } saved, err := target.Save(ctx, setupAccountConfig(result.Email, options), result.StorageState, loginDirectory) if err != nil { + fmt.Fprintf(os.Stderr, "保存账户失败,临时目录已保留: %s\n", loginDirectory) return err } + keepOnFailure = false fmt.Fprintf(os.Stdout, "账户已保存: %s\n", saved) if result.DriveError != "" { fmt.Fprintf(os.Stdout, "Drive 授权失败: %s\n", result.DriveError) From dda8fee5489e7cdef87795b0b38132814118e603 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Mon, 19 Oct 2026 23:12:20 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E7=99=BB=E5=BD=95=E5=85=B3=E9=97=AD?= =?UTF-8?q?=20Camoufox=20=E4=BF=9D=E7=95=99=E8=B4=A6=E6=88=B7=E5=B9=B6?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E8=BF=9B=E7=A8=8B=E6=A0=91=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=89=8B=E5=8A=A8=E6=B8=85=E9=99=A4=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导出认证状态后关闭 Camoufox 失败时仍保存账户,setup 输出与管理端请求日志报告关闭失败原因 - Windows 上每个 Camoufox 归入独立 Job,关闭时结束含父进程已退出在内的全部子孙进程后删除 profile - 冷却列表返回作用键,管理端冷却详情可手动清除模型、Build 与账户级冷却 - 额度冷却按上游 RetryDelay 计时,账户限额由管理端手动清除冷却处理 - 关闭 Camoufox 最多等待 5 秒,setup 结束时总是删除临时登录目录 --- internal/aistudio/accounts.go | 12 ++ internal/aistudio/auth.go | 1 + internal/aistudio/login_native.go | 3 +- internal/aistudio/quota.go | 19 --- internal/aistudio/quota_zerolimit_test.go | 187 --------------------- internal/api/admin.go | 11 ++ internal/app/admin.go | 30 +++- internal/app/lifecycle.go | 7 + internal/camoufoxnative/launcher.go | 30 ++-- internal/camoufoxnative/login.go | 23 +-- internal/camoufoxnative/process_other.go | 13 +- internal/camoufoxnative/process_windows.go | 98 +++++------ internal/setup/setup.go | 23 +-- web/src/api.ts | 5 + web/src/components/RequestsPanel.vue | 30 ++++ web/src/locales/de.ts | 4 + web/src/locales/en.ts | 4 + web/src/locales/fr.ts | 4 + web/src/locales/ja.ts | 4 + web/src/locales/ko.ts | 4 + web/src/locales/zh-CN.ts | 3 + web/src/locales/zh-TW.ts | 3 + web/src/types.ts | 1 + 23 files changed, 206 insertions(+), 313 deletions(-) delete mode 100644 internal/aistudio/quota_zerolimit_test.go diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index d4bd589..1707a40 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -2193,6 +2193,18 @@ func (p *AccountPool) ClearCooldownIfGeneration( return err } +// ClearCooldown 清除账户在作用域上的冷却,作用域为全局 *、模型 ID 或 build:<模型 ID> +func (p *AccountPool) ClearCooldown(accountID string, scope string) error { + _, err := p.updateRuntime(accountID, func(_ *Account, runtimeState *accountRuntimeState) (bool, func(*Account), error) { + if _, exists := runtimeState.Cooldowns[scope]; !exists { + return false, nil, nil + } + delete(runtimeState.Cooldowns, scope) + return true, nil, nil + }) + return err +} + // BindResource 将资源 ID 固定到创建账户 func (p *AccountPool) BindResource(resourceID string, accountID string) error { return p.BindResourceKind(resourceID, accountID, "") diff --git a/internal/aistudio/auth.go b/internal/aistudio/auth.go index 205dcbb..be1e3d8 100644 --- a/internal/aistudio/auth.go +++ b/internal/aistudio/auth.go @@ -112,6 +112,7 @@ type IsolatedLoginResult struct { Email string VerifiedAt time.Time DriveError string + CloseError string } // IsolatedLoginDriver 定义独立浏览器登录 diff --git a/internal/aistudio/login_native.go b/internal/aistudio/login_native.go index 158509c..ea818e1 100644 --- a/internal/aistudio/login_native.go +++ b/internal/aistudio/login_native.go @@ -66,7 +66,8 @@ func (driver *NativeLoginDriver) Login(ctx context.Context, request IsolatedLogi return IsolatedLoginResult{}, err } return IsolatedLoginResult{ - StorageState: state, Email: result.Email, VerifiedAt: result.VerifiedAt, DriveError: result.DriveError, + StorageState: state, Email: result.Email, VerifiedAt: result.VerifiedAt, + DriveError: result.DriveError, CloseError: result.CloseError, }, nil } diff --git a/internal/aistudio/quota.go b/internal/aistudio/quota.go index 4d37c14..b9ef27e 100644 --- a/internal/aistudio/quota.go +++ b/internal/aistudio/quota.go @@ -68,25 +68,6 @@ func QuotaCooldownForError(err error, now time.Time) (QuotaCooldown, bool) { if zeroQuotaLimit(rpcError.Message) { kind = "无配额" } - if kind == "无配额" { - // 上游把所有额度项都报成 limit: 0 —— 这是**账号没有该模型的访问权限**, - // 不是额度用完了。两者的区别在恢复方式上: - // 额度用完 到额度日重置(次日)恢复,等是对的 - // limit: 0 权限问题,等多久都不会变 - // - // 原实现把 until 交给 RetryDelay(上游给的 "retry in 19h"),于是 - // 每天只探测一次、撞一次、再等一天。实测 9/9 条 retry in 精确指向 - // UTC 零点,但次日同一模型仍是 limit: 0 —— 白白浪费一天。 - // - // 改为走递增退避(1m → 5m → 15m → 30m → 1h),一小时内最多重探一次。 - // 权限若真的开通了,一小时内就能发现;若没开通,代价也只是每小时一次 - // 失败的尝试,远小于每天干等 19 小时。 - // - // Escalate 让 MarkQuotaCooldownIfGeneration 走 planLimitCooldown 分支, - // 那里会读上一次的 Level 并递增。 - return QuotaCooldown{Until: now.Add(planLimitBackoff[0]), Global: global, - Kind: kind, Reason: kind + ": " + err.Error(), Escalate: true}, true - } escalate := false if rpcError.RetryDelay > 0 { until = now.Add(rpcError.RetryDelay) diff --git a/internal/aistudio/quota_zerolimit_test.go b/internal/aistudio/quota_zerolimit_test.go deleted file mode 100644 index 764a73e..0000000 --- a/internal/aistudio/quota_zerolimit_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package aistudio - -import ( - "errors" - "fmt" - "testing" - "time" -) - -// rpc429 构造一个 429 RPCError,message 里带 limit: N -func rpc429(message string, retry time.Duration) error { - return &RPCError{ - Method: "ProxyUnaryCall", - StatusCode: 429, - Code: 8, - Message: message, - Metadata: map[string]string{}, - RetryDelay: retry, - } -} - -// TestZeroQuotaUsesBackoff 验证 limit: 0 走递增退避,而不是等 RetryDelay -func TestZeroQuotaUsesBackoff(t *testing.T) { - now := time.Date(2026, 10, 19, 4, 49, 51, 0, time.UTC) - // 上游原文:所有额度项都是 limit: 0,附带 retry in 19h10m - msg := "Quota exceeded for metric: generate_requests_per_model_per_user, limit: 0, model: gemini-4-argon\n" + - "Please retry in 19h10m8s." - err := rpc429(msg, 19*time.Hour+10*time.Minute+8*time.Second) - - cd, ok := QuotaCooldownForError(err, now) - if !ok { - t.Fatal("应识别为额度冷却") - } - if cd.Kind != "无配额" { - t.Fatalf("Kind 应为 无配额,实际 %q", cd.Kind) - } - if !cd.Escalate { - t.Error("无配额应设 Escalate=true 以走递增退避") - } - // 关键:不能等于 now+19h10m - want := now.Add(planLimitBackoff[0]) // 1 分钟 - if !cd.Until.Equal(want) { - t.Errorf("Until 应为 %v(%v 后),实际 %v(%v 后)", - want, planLimitBackoff[0], cd.Until, cd.Until.Sub(now)) - } - if cd.Until.Sub(now) > 2*time.Minute { - t.Errorf("冷却不应超过 2 分钟,实际 %v", cd.Until.Sub(now)) - } -} - -// TestRealQuotaStillUsesRetryDelay 验证真额度用完仍按上游给的时间恢复 -func TestRealQuotaStillUsesRetryDelay(t *testing.T) { - now := time.Date(2026, 10, 19, 4, 49, 51, 0, time.UTC) - // limit: 100 —— 是真额度,不是权限问题 - msg := "Quota exceeded for metric: generate_requests_per_model_per_user, limit: 100, model: gemini-3.8-flash\n" + - "Please retry in 19h10m3s." - retry := 19*time.Hour + 10*time.Minute + 3*time.Second - err := rpc429(msg, retry) - - cd, ok := QuotaCooldownForError(err, now) - if !ok { - t.Fatal("应识别为额度冷却") - } - if cd.Kind == "无配额" { - t.Error("limit: 100 不应被判为无配额") - } - want := now.Add(retry) - if !cd.Until.Equal(want) { - t.Errorf("Until 应为 %v,实际 %v", want, cd.Until) - } -} - -// TestMixedLimitIsNotZeroQuota 验证 limit 里有非零值时不算无配额 -func TestMixedLimitIsNotZeroQuota(t *testing.T) { - now := time.Now() - msg := "Quota exceeded for metric: a, limit: 0, model: x\n" + - "Quota exceeded for metric: b, limit: 50, model: x\nPlease retry in 1h." - err := rpc429(msg, time.Hour) - - cd, _ := QuotaCooldownForError(err, now) - if cd.Kind == "无配额" { - t.Error("有任一非零 limit 时不应判为无配额") - } -} - -// TestNoLimitTokenIsNotZeroQuota 验证错误里没有 limit 时不误判 -func TestNoLimitTokenIsNotZeroQuota(t *testing.T) { - now := time.Now() - err := rpc429("Rate exceeded, please slow down.", time.Minute) - - cd, _ := QuotaCooldownForError(err, now) - if cd.Kind == "无配额" { - t.Error("没有 limit 字段时不应判为无配额") - } -} - -// TestBackoffEscalates 验证连续无配额时冷却逐级拉长 -func TestBackoffEscalates(t *testing.T) { - base := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) - reason := "无配额: test" - - // 第一次:1 分钟 - first := planLimitCooldown(CooldownState{}, false, base, reason) - if got := first.Until.Sub(base); got != time.Minute { - t.Fatalf("第一次应为 1 分钟,实际 %v", got) - } - if first.Level != 1 { - t.Fatalf("第一次 Level 应为 1,实际 %d", first.Level) - } - - // 第二次(上次已过期):5 分钟 - second := planLimitCooldown(first, true, base.Add(2*time.Minute), reason) - if got := second.Until.Sub(base.Add(2 * time.Minute)); got != 5*time.Minute { - t.Errorf("第二次应为 5 分钟,实际 %v", got) - } - if second.Level != 2 { - t.Errorf("第二次 Level 应为 2,实际 %d", second.Level) - } - - // 第三次:15 分钟 - third := planLimitCooldown(second, true, base.Add(time.Hour), reason) - if got := third.Until.Sub(base.Add(time.Hour)); got != 15*time.Minute { - t.Errorf("第三次应为 15 分钟,实际 %v", got) - } - - // 封顶 1 小时 - prev := third - at := base.Add(2 * time.Hour) - for i := 0; i < 5; i++ { - next := planLimitCooldown(prev, true, at, reason) - prev = next - at = at.Add(2 * time.Hour) - } - if got := prev.Until.Sub(at.Add(-2 * time.Hour)); got != time.Hour { - t.Errorf("封顶应为 1 小时,实际 %v", got) - } - if prev.Level != len(planLimitBackoff) { - t.Errorf("Level 应封顶为 %d,实际 %d", len(planLimitBackoff), prev.Level) - } -} - -// TestBackoffKeepsActiveCooldown 验证冷却未结束时沿用原值不重置 -func TestBackoffKeepsActiveCooldown(t *testing.T) { - base := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) - prev := CooldownState{Until: base.Add(10 * time.Minute), Reason: "x", Level: 3} - got := planLimitCooldown(prev, true, base, "y") - if !got.Until.Equal(prev.Until) || got.Level != prev.Level { - t.Errorf("冷却未结束时应原样返回,实际 %+v", got) - } -} - -// TestZeroQuotaKeepsGlobalFlag 验证 global 标记在无配额分支里仍保留 -func TestZeroQuotaKeepsGlobalFlag(t *testing.T) { - now := time.Now() - err := &RPCError{ - Method: "GenerateContent", StatusCode: 429, Code: 8, - Message: "Quota exceeded, limit: 0, model: x", - Metadata: map[string]string{"quota_metric": "generate_requests_per_project_per_user_global"}, - RetryDelay: 19 * time.Hour, - } - cd, ok := QuotaCooldownForError(err, now) - if !ok { - t.Fatal("应识别") - } - if !cd.Global { - t.Error("per_project_per_user_global 应设 Global=true") - } - if cd.Kind != "无配额" { - t.Errorf("Kind 应为 无配额,实际 %q", cd.Kind) - } -} - -// TestNon429Ignored 验证非 429 不产生冷却 -func TestNon429Ignored(t *testing.T) { - now := time.Now() - err := &RPCError{StatusCode: 500, Message: "boom"} - if _, ok := QuotaCooldownForError(err, now); ok { - t.Error("500 不应产生额度冷却") - } - var plain = errors.New("network") - if _, ok := QuotaCooldownForError(plain, now); ok { - t.Error("普通错误不应产生额度冷却") - } -} - -// 确保 fmt 被使用(调试时方便) -var _ = fmt.Sprintf diff --git a/internal/api/admin.go b/internal/api/admin.go index e250128..20b570d 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -28,6 +28,7 @@ type AdminService interface { RuntimeConfig(context.Context) (RuntimeConfig, error) UpdateRuntimeConfig(context.Context, RuntimeConfig) (RuntimeConfig, error) Cooldowns(context.Context) ([]AdminCooldown, error) + ClearCooldown(ctx context.Context, accountID string, scope string) error Requests(context.Context) ([]AdminRequest, error) CancelRequest(context.Context, string) error Events(context.Context) (<-chan AdminEvent, error) @@ -241,6 +242,7 @@ type AdminCooldown struct { AccountLabel string `json:"account_label"` Channel string `json:"channel"` ModelID string `json:"model_id"` + Scope string `json:"scope"` Until time.Time `json:"until"` Reason string `json:"reason,omitempty"` } @@ -278,6 +280,7 @@ func (s *server) registerAdmin(mux *http.ServeMux) { mux.HandleFunc("GET /api/config", s.handleRuntimeConfig) mux.HandleFunc("PUT /api/config", s.handleUpdateRuntimeConfig) mux.HandleFunc("GET /api/cooldowns", s.handleCooldowns) + mux.HandleFunc("DELETE /api/accounts/{id}/cooldowns/{scope}", s.handleClearCooldown) mux.HandleFunc("GET /api/requests", s.handleRequests) mux.HandleFunc("POST /api/requests/{id}/cancel", s.handleCancelRequest) mux.HandleFunc("GET /api/events", s.handleAdminEvents) @@ -457,6 +460,14 @@ func (s *server) handleCooldowns(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string][]AdminCooldown{"cooldowns": cooldowns}) } +func (s *server) handleClearCooldown(w http.ResponseWriter, r *http.Request) { + if err := s.config.Admin.ClearCooldown(r.Context(), r.PathValue("id"), r.PathValue("scope")); err != nil { + writeAdminUpstreamError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *server) handleRequests(w http.ResponseWriter, r *http.Request) { requests, err := s.config.Admin.Requests(r.Context()) if err != nil { diff --git a/internal/app/admin.go b/internal/app/admin.go index 2f3de19..d4b6771 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -193,6 +193,9 @@ func (admin *runtimeAdmin) CreateAccount(ctx context.Context, input api.AccountC if result.DriveError != "" { admin.requests.log("auth", "WARN", "账户添加 | Drive 授权失败 | 错误="+result.DriveError) } + if result.CloseError != "" { + admin.requests.log("auth", "WARN", "账户添加 | 关闭 Camoufox 失败 | 错误="+result.CloseError) + } admin.requests.log("auth", "INFO", "账户添加 | 2/2 | 保存认证状态") if _, err := aistudio.NewSigner().Sign(result.StorageState); err != nil { return api.AdminAccount{}, fmt.Errorf("认证状态无法用于 AI Studio: %w", err) @@ -507,6 +510,9 @@ func (admin *runtimeAdmin) LoginAccount(ctx context.Context, accountID string) ( if result.DriveError != "" { admin.requests.log(account.Config.Label, "WARN", "账户登录 | Drive 授权失败 | 错误="+result.DriveError) } + if result.CloseError != "" { + admin.requests.log(account.Config.Label, "WARN", "账户登录 | 关闭 Camoufox 失败 | 错误="+result.CloseError) + } admin.requests.log(account.Config.Label, "INFO", "账户登录 | 2/2 | 保存认证状态") if _, err := aistudio.NewSigner().Sign(result.StorageState); err != nil { return api.AdminAccount{}, errors.Join(fmt.Errorf("认证状态无法用于 AI Studio: %w", err), lease.Release()) @@ -840,9 +846,11 @@ func (admin *runtimeAdmin) Cooldowns(context.Context) ([]api.AdminCooldown, erro models[modelID] = struct{}{} } effective := make(map[string]aistudio.CooldownState) + scopes := make(map[string]string) if global, ok := account.Cooldowns["*"]; ok && global.Active(now) { for modelID := range models { effective[modelID] = global + scopes[modelID] = "*" } } for key, cooldown := range account.Cooldowns { @@ -855,6 +863,7 @@ func (admin *runtimeAdmin) Cooldowns(context.Context) ([]api.AdminCooldown, erro } if current, ok := effective[key]; !ok || cooldown.Until.After(current.Until) { effective[key] = cooldown + scopes[key] = key } } modelIDs := make([]string, 0, len(effective)) @@ -873,13 +882,32 @@ func (admin *runtimeAdmin) Cooldowns(context.Context) ([]api.AdminCooldown, erro } cooldowns = append(cooldowns, api.AdminCooldown{ AccountID: account.ID, AccountLabel: account.Label, Channel: channel, - ModelID: modelID, Until: cooldown.Until, Reason: cooldown.Reason, + ModelID: modelID, Scope: scopes[key], Until: cooldown.Until, Reason: cooldown.Reason, }) } } return cooldowns, nil } +// ClearCooldown 手动清除账户在一个作用域上的冷却,并推送新的冷却列表与账户状态 +func (admin *runtimeAdmin) ClearCooldown(ctx context.Context, accountID string, scope string) error { + account, err := admin.account(accountID) + if err != nil { + return err + } + if err := admin.pool.ClearCooldown(accountID, scope); err != nil { + return err + } + admin.requests.log(account.Label, "INFO", "冷却已手动清除 | 范围="+scope) + if cooldowns, err := admin.Cooldowns(ctx); err == nil { + admin.requests.publish(api.AdminEvent{Type: "cooldowns", Data: cooldowns}) + } + if accounts, err := admin.Accounts(ctx); err == nil { + admin.requests.publish(api.AdminEvent{Type: "accounts", Data: map[string]any{"accounts": accounts}}) + } + return nil +} + func (admin *runtimeAdmin) Requests(context.Context) ([]api.AdminRequest, error) { return admin.requests.list(), nil } diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index 6286626..6a987df 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -468,6 +468,13 @@ func (manager *runtimeManager) Cooldowns(ctx context.Context) ([]api.AdminCooldo return manager.current.admin.Cooldowns(ctx) } +// ClearCooldown 手动清除账户在一个作用域上的冷却 +func (manager *runtimeManager) ClearCooldown(ctx context.Context, accountID string, scope string) error { + manager.mu.RLock() + defer manager.mu.RUnlock() + return manager.current.admin.ClearCooldown(ctx, accountID, scope) +} + // Requests 返回进程级活动请求 func (manager *runtimeManager) Requests(ctx context.Context) ([]api.AdminRequest, error) { manager.mu.RLock() diff --git a/internal/camoufoxnative/launcher.go b/internal/camoufoxnative/launcher.go index 013eaf2..e1b1a54 100644 --- a/internal/camoufoxnative/launcher.go +++ b/internal/camoufoxnative/launcher.go @@ -26,15 +26,9 @@ import ( var bidiEndpointPattern = regexp.MustCompile(`ws://[^\s]+`) -// browserProcessCloseTimeout 是等待 Camoufox 进程退出的上限。 -// -// 原值 5s 对 900MB 级的浏览器太短:登录流程走完后浏览器要落盘 profile、 -// 回收渲染进程,实测经常超过 5 秒。超时本身不致命(登录状态早已抓到), -// 但会让日志刷出「等待 Camoufox 进程退出」的假失败。放到 20s 覆盖正常关闭, -// 真正的僵死进程仍会在这里被截断。 -const browserProcessCloseTimeout = 20 * time.Second +const browserProcessCloseTimeout = 5 * time.Second -type browserProcessTerminator func(context.Context, *exec.Cmd) error +type browserProcessTerminator func(context.Context, *browserProcess) error type browserProcess struct { command *exec.Cmd @@ -42,6 +36,7 @@ type browserProcess struct { waitErr error profile string lock *flock.Flock + tree browserTree mu sync.Mutex closed bool } @@ -146,7 +141,8 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) _ = removeProfile(profile, profileLock) return nil, "", fmt.Errorf("启动 Camoufox: %w", err) } - if err := attachBrowserProcess(command); err != nil { + tree, err := attachBrowserProcess(command) + if err != nil { _ = command.Process.Kill() _ = command.Wait() _ = removeProfile(profile, profileLock) @@ -157,6 +153,7 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) done: make(chan struct{}), profile: profile, lock: profileLock, + tree: tree, } launched = true go func() { @@ -187,9 +184,8 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) case <-process.done: process.mu.Lock() err := process.waitErr - process.closed = true process.mu.Unlock() - _ = removeProfile(profile, profileLock) + _ = process.Close() if err == nil { err = errors.New("Camoufox 在报告 BiDi 端点前退出") } @@ -223,16 +219,11 @@ func (process *browserProcess) close(timeout time.Duration, terminate browserPro } closeCtx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - var closeErr error + closeErr := terminate(closeCtx, process) select { case <-process.done: - default: - closeErr = terminate(closeCtx, process.command) - select { - case <-process.done: - case <-closeCtx.Done(): - closeErr = errors.Join(closeErr, fmt.Errorf("等待 Camoufox 进程退出: %w", closeCtx.Err())) - } + case <-closeCtx.Done(): + closeErr = errors.Join(closeErr, fmt.Errorf("等待 Camoufox 进程退出: %w", closeCtx.Err())) } closeErr = errors.Join(closeErr, removeProfile(process.profile, process.lock)) if closeErr != nil { @@ -241,6 +232,7 @@ func (process *browserProcess) close(timeout time.Duration, terminate browserPro process.mu.Lock() process.closed = true process.mu.Unlock() + releaseBrowserTree(process.tree) return nil } diff --git a/internal/camoufoxnative/login.go b/internal/camoufoxnative/login.go index a96e9a7..9818a51 100644 --- a/internal/camoufoxnative/login.go +++ b/internal/camoufoxnative/login.go @@ -43,6 +43,7 @@ type LoginResult struct { PageURL string VerifiedAt time.Time DriveError string + CloseError string } type loginSession struct { @@ -65,24 +66,12 @@ func Login(ctx context.Context, options LoginOptions) (result LoginResult, err e return LoginResult{}, err } defer func() { - // Close 失败不能污染登录结果。 - // - // Camoufox 是 900MB 级的浏览器进程,关闭经常超过 - // browserProcessCloseTimeout(5s)。原实现把 Close 的错误 - // errors.Join 进返回值,于是「登录成功、状态已抓到、只差关窗口」 - // 被判为整体失败,调用方 importIsolatedLogin 随即 defer - // os.RemoveAll(loginDirectory) 把刚抓到的 storage-state 删掉。 - // - // 实测:登录页正常走完、自动关闭窗口,最后报 - // 「等待 Camoufox 进程退出: context deadline exceeded」, - // 而 auth 目录里的文件时间戳没变 —— 状态确实被丢了。 - // - // 关闭失败只说明进程回收不干净,与登录结果无关: - // 状态已经在上面的 exportStorageState 里拿到并编码完毕。 - // 这里只记录,不改变 err。 - if closeErr := session.Close(); closeErr != nil && err == nil { - fmt.Fprintf(os.Stderr, "警告: 关闭 Camoufox 失败(不影响登录结果): %v\n", closeErr) + closeErr := session.Close() + if err == nil && closeErr != nil { + result.CloseError = closeErr.Error() + return } + err = errors.Join(err, closeErr) }() origins := make(map[string]storageOrigin) pageURL, err := session.waitLogin(loginCtx, origins) diff --git a/internal/camoufoxnative/process_other.go b/internal/camoufoxnative/process_other.go index fb13bd2..1b1f723 100644 --- a/internal/camoufoxnative/process_other.go +++ b/internal/camoufoxnative/process_other.go @@ -15,13 +15,20 @@ func configureBrowserProcess(command *exec.Cmd, _ bool) { command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } +// browserTree 在非 Windows 平台由进程组表示,不需要额外句柄 +type browserTree struct{} + // attachBrowserProcess 在非 Windows 平台由进程组负责回收 -func attachBrowserProcess(*exec.Cmd) error { - return nil +func attachBrowserProcess(*exec.Cmd) (browserTree, error) { + return browserTree{}, nil } +// releaseBrowserTree 在非 Windows 平台无需释放 +func releaseBrowserTree(browserTree) {} + // terminateBrowserProcess 结束 Camoufox 进程组 -func terminateBrowserProcess(ctx context.Context, command *exec.Cmd) error { +func terminateBrowserProcess(ctx context.Context, process *browserProcess) error { + command := process.command if command == nil || command.Process == nil { return nil } diff --git a/internal/camoufoxnative/process_windows.go b/internal/camoufoxnative/process_windows.go index 35995b9..c762340 100644 --- a/internal/camoufoxnative/process_windows.go +++ b/internal/camoufoxnative/process_windows.go @@ -4,20 +4,16 @@ package camoufoxnative import ( "context" - "errors" "fmt" - "os" "os/exec" - "strconv" "sync" "syscall" + "time" "unsafe" "golang.org/x/sys/windows" ) -const windowsStillActive = 259 - var ( browserJobOnce sync.Once browserJob windows.Handle @@ -58,67 +54,75 @@ func serviceBrowserJob() (windows.Handle, error) { return browserJob, browserJobErr } -// attachBrowserProcess 把挂起的 Camoufox 加入服务 Job 后恢复执行 -func attachBrowserProcess(command *exec.Cmd) error { +// browserTree 是只含一个 Camoufox 及其全部子孙进程的嵌套 Job +type browserTree windows.Handle + +// jobAccountingInformation 是 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION +type jobAccountingInformation struct { + TotalUserTime int64 + TotalKernelTime int64 + ThisPeriodTotalUserTime int64 + ThisPeriodTotalKernelTime int64 + TotalPageFaultCount uint32 + TotalProcesses uint32 + ActiveProcesses uint32 + TotalTerminatedProcesses uint32 +} + +// attachBrowserProcess 把挂起的 Camoufox 加入服务 Job 与它自己的嵌套 Job 后恢复执行,返回该嵌套 Job +func attachBrowserProcess(command *exec.Cmd) (browserTree, error) { job, err := serviceBrowserJob() if err != nil { - return err + return 0, err } process, err := windows.OpenProcess(browserJobAccess, false, uint32(command.Process.Pid)) if err != nil { - return fmt.Errorf("打开 Camoufox 进程: %w", err) + return 0, fmt.Errorf("打开 Camoufox 进程: %w", err) } defer windows.CloseHandle(process) if err := windows.AssignProcessToJobObject(job, process); err != nil { - return fmt.Errorf("加入 Camoufox Job: %w", err) + return 0, fmt.Errorf("加入 Camoufox Job: %w", err) + } + tree, err := windows.CreateJobObject(nil, nil) + if err != nil { + return 0, fmt.Errorf("创建 Camoufox 进程树 Job: %w", err) + } + if err := windows.AssignProcessToJobObject(tree, process); err != nil { + windows.CloseHandle(tree) + return 0, fmt.Errorf("加入 Camoufox 进程树 Job: %w", err) } if status, _, _ := ntResumeProcess.Call(uintptr(process)); status != 0 { - return fmt.Errorf("恢复 Camoufox 进程: NTSTATUS 0x%x", status) + windows.CloseHandle(tree) + return 0, fmt.Errorf("恢复 Camoufox 进程: NTSTATUS 0x%x", status) } - return nil + return browserTree(tree), nil } -// terminateBrowserProcess 结束 Camoufox 及其全部子进程 -func terminateBrowserProcess(ctx context.Context, command *exec.Cmd) error { - if command == nil || command.Process == nil { - return nil +// terminateBrowserProcess 结束 Camoufox 嵌套 Job 中的全部进程并等待它们退出 +func terminateBrowserProcess(ctx context.Context, process *browserProcess) error { + tree := windows.Handle(process.tree) + if err := windows.TerminateJobObject(tree, 1); err != nil { + return fmt.Errorf("结束 Camoufox 进程树: %w", err) } - if err := ctx.Err(); err != nil { - return err - } - pid := command.Process.Pid - if !browserProcessActive(pid) { - return nil - } - kill := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F") - kill.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} - if output, err := kill.CombinedOutput(); err != nil { - if !browserProcessActive(pid) { - return nil + for { + var info jobAccountingInformation + if err := windows.QueryInformationJobObject( + tree, windows.JobObjectBasicAccountingInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), nil, + ); err != nil { + return fmt.Errorf("查询 Camoufox 进程树: %w", err) } - taskkillErr := fmt.Errorf("taskkill 结束 Camoufox 进程树 PID=%d: %w: %s", pid, err, output) - directKillErr := command.Process.Kill() - if directKillErr == nil || errors.Is(directKillErr, os.ErrProcessDone) || !browserProcessActive(pid) { + if info.ActiveProcesses == 0 { return nil } - terminateErr := errors.Join(taskkillErr, fmt.Errorf("Process.Kill 结束 Camoufox PID=%d: %w", pid, directKillErr)) - if ctxErr := ctx.Err(); ctxErr != nil { - return errors.Join(terminateErr, ctxErr) + select { + case <-ctx.Done(): + return fmt.Errorf("等待 Camoufox 进程树退出: %d 个进程仍在运行: %w", info.ActiveProcesses, ctx.Err()) + case <-time.After(20 * time.Millisecond): } - return terminateErr } - return nil } -func browserProcessActive(pid int) bool { - handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) - if err != nil { - return !errors.Is(err, windows.ERROR_INVALID_PARAMETER) - } - defer windows.CloseHandle(handle) - var exitCode uint32 - if err := windows.GetExitCodeProcess(handle, &exitCode); err != nil { - return true - } - return exitCode == windowsStillActive +// releaseBrowserTree 关闭嵌套 Job 句柄 +func releaseBrowserTree(tree browserTree) { + windows.CloseHandle(windows.Handle(tree)) } diff --git a/internal/setup/setup.go b/internal/setup/setup.go index fb0c8dc..335d40e 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -243,43 +243,28 @@ func importIsolatedLogin( if err != nil { return fmt.Errorf("创建隔离登录目录: %w", err) } - // 只在成功时删临时目录。 - // - // 原实现无条件 defer os.RemoveAll,于是推送失败(pairing 409、令牌过期、 - // 网络不通)时,刚抓到的 storage-state 连同指纹一起被删 —— 用户白登一次。 - // 实测踩过两次:一次 409「账户已存在」,一次令牌过期。 - // - // 失败时保留目录并把路径打出来,用户可以手工导入: - // aistudio2api setup --storage-state <路径>/storage-state.json - keepOnFailure := true - defer func() { - if keepOnFailure { - return - } - _ = os.RemoveAll(loginDirectory) - }() + defer os.RemoveAll(loginDirectory) result, err := driver.Login(ctx, aistudio.IsolatedLoginRequest{ AccountID: "setup", Directory: loginDirectory, Proxy: options.egress, Locale: options.locale, Timezone: options.timezone, }) if err != nil { - fmt.Fprintf(os.Stderr, "隔离登录失败,临时目录已保留: %s\n", loginDirectory) return err } if _, err := aistudio.NewSigner().Sign(result.StorageState); err != nil { - fmt.Fprintf(os.Stderr, "认证状态不可用,临时目录已保留: %s\n", loginDirectory) return fmt.Errorf("认证状态无法用于 AI Studio: %w", err) } saved, err := target.Save(ctx, setupAccountConfig(result.Email, options), result.StorageState, loginDirectory) if err != nil { - fmt.Fprintf(os.Stderr, "保存账户失败,临时目录已保留: %s\n", loginDirectory) return err } - keepOnFailure = false fmt.Fprintf(os.Stdout, "账户已保存: %s\n", saved) if result.DriveError != "" { fmt.Fprintf(os.Stdout, "Drive 授权失败: %s\n", result.DriveError) } + if result.CloseError != "" { + fmt.Fprintf(os.Stdout, "关闭 Camoufox 失败: %s\n", result.CloseError) + } return nil } diff --git a/web/src/api.ts b/web/src/api.ts index e4c2732..97baf88 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -211,6 +211,11 @@ export const api = { requestCommand(`/api/requests/${encodeURIComponent(id)}/cancel`, { method: 'POST', }), + clearCooldown: (accountID: string, scope: string) => + requestCommand( + `/api/accounts/${encodeURIComponent(accountID)}/cooldowns/${encodeURIComponent(scope)}`, + { method: 'DELETE' }, + ), } // openAdminEvents 建立唯一的管理状态 SSE 连接 diff --git a/web/src/components/RequestsPanel.vue b/web/src/components/RequestsPanel.vue index 74e9cf5..8a945e1 100644 --- a/web/src/components/RequestsPanel.vue +++ b/web/src/components/RequestsPanel.vue @@ -25,6 +25,7 @@ const cancelling = ref('') const refreshing = ref(false) const selectedCategory = ref('') const detail = ref(null) +const clearing = ref(false) // splitReason 把冷却原因拆成冷却类型与上游错误原文 function splitReason(reason: string | undefined): { category: string; text: string } { @@ -117,6 +118,21 @@ async function cancelRequest(request: RequestSummary): Promise { cancelling.value = '' } } + +// clearCooldown 手动清除详情中的冷却,成功后关闭详情并刷新列表 +async function clearCooldown(cooldown: Cooldown): Promise { + clearing.value = true + try { + await api.clearCooldown(cooldown.account_id, cooldown.scope) + detail.value = null + emit('notice', t('cooldowns.cleared'), 'success') + emit('refresh') + } catch (error) { + emit('notice', errorText(error), 'error') + } finally { + clearing.value = false + } +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 08917ce..bfa2ab2 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -193,6 +193,10 @@ export const messages: Record = { 'requests.empty': 'Keine laufenden Anfragen', 'cooldowns.title': 'Modell-Cooldowns', 'cooldowns.until': 'Verfügbar ab', + 'cooldowns.clear': 'Cooldown aufheben', + 'cooldowns.cleared': 'Cooldown aufgehoben', + 'cooldowns.clearAccountHint': + 'Cooldown für das ganze Konto, gilt für alle Modelle dieses Kontos; modellspezifische Cooldowns stehen separat', 'cooldowns.all': 'Alle', 'cooldowns.other': 'Sonstige', 'cooldowns.category': 'Kategorie', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 453f17a..5b803b8 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -190,6 +190,10 @@ export const messages: Record = { 'requests.empty': 'No current requests', 'cooldowns.title': 'Model cooldowns', 'cooldowns.until': 'Available at', + 'cooldowns.clear': 'Clear cooldown', + 'cooldowns.cleared': 'Cooldown cleared', + 'cooldowns.clearAccountHint': + 'Account-wide cooldown that applies to every model on this account; model-specific cooldowns are listed separately', 'cooldowns.all': 'All', 'cooldowns.other': 'Other', 'cooldowns.category': 'Category', diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 0bcee2f..8fb5f62 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -193,6 +193,10 @@ export const messages: Record = { 'requests.empty': 'Aucune requête en cours', 'cooldowns.title': 'Refroidissements par modèle', 'cooldowns.until': 'Disponible à', + 'cooldowns.clear': 'Lever le refroidissement', + 'cooldowns.cleared': 'Refroidissement levé', + 'cooldowns.clearAccountHint': + 'Refroidissement de tout le compte, appliqué à tous les modèles de ce compte ; les refroidissements propres à un modèle sont listés à part', 'cooldowns.all': 'Tout', 'cooldowns.other': 'Autre', 'cooldowns.category': 'Catégorie', diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 1db77ca..8219a48 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -193,6 +193,10 @@ export const messages: Record = { 'requests.empty': '現在リクエストはありません', 'cooldowns.title': 'モデルのクールダウン', 'cooldowns.until': '回復予定', + 'cooldowns.clear': 'クールダウンを解除', + 'cooldowns.cleared': 'クールダウンを解除しました', + 'cooldowns.clearAccountHint': + 'アカウント全体のクールダウンで、このアカウントのすべてのモデルに適用されます。モデル個別のクールダウンは別に表示されます', 'cooldowns.all': 'すべて', 'cooldowns.other': 'その他', 'cooldowns.category': '種類', diff --git a/web/src/locales/ko.ts b/web/src/locales/ko.ts index e32dd8b..2e0661c 100644 --- a/web/src/locales/ko.ts +++ b/web/src/locales/ko.ts @@ -191,6 +191,10 @@ export const messages: Record = { 'requests.empty': '현재 요청이 없습니다', 'cooldowns.title': '모델 쿨다운', 'cooldowns.until': '복구 시간', + 'cooldowns.clear': '쿨다운 해제', + 'cooldowns.cleared': '쿨다운을 해제했습니다', + 'cooldowns.clearAccountHint': + '계정 전체 쿨다운으로 이 계정의 모든 모델에 적용됩니다. 모델별 쿨다운은 따로 표시됩니다', 'cooldowns.all': '전체', 'cooldowns.other': '기타', 'cooldowns.category': '유형', diff --git a/web/src/locales/zh-CN.ts b/web/src/locales/zh-CN.ts index 9792418..cda9ffc 100644 --- a/web/src/locales/zh-CN.ts +++ b/web/src/locales/zh-CN.ts @@ -185,6 +185,9 @@ export const messages = { 'requests.empty': '当前没有请求', 'cooldowns.title': '模型冷却', 'cooldowns.until': '恢复时间', + 'cooldowns.clear': '清除冷却', + 'cooldowns.cleared': '已清除冷却', + 'cooldowns.clearAccountHint': '账户级冷却,作用于该账户全部模型;模型自身的冷却单独列出', 'cooldowns.all': '全部', 'cooldowns.other': '其他', 'cooldowns.category': '类别', diff --git a/web/src/locales/zh-TW.ts b/web/src/locales/zh-TW.ts index cdd7d04..9355da7 100644 --- a/web/src/locales/zh-TW.ts +++ b/web/src/locales/zh-TW.ts @@ -188,6 +188,9 @@ export const messages: Record = { 'requests.empty': '目前沒有請求', 'cooldowns.title': '模型冷卻', 'cooldowns.until': '恢復時間', + 'cooldowns.clear': '清除冷卻', + 'cooldowns.cleared': '已清除冷卻', + 'cooldowns.clearAccountHint': '帳號層級冷卻,作用於該帳號所有模型;模型本身的冷卻另行列出', 'cooldowns.all': '全部', 'cooldowns.other': '其他', 'cooldowns.category': '類別', diff --git a/web/src/types.ts b/web/src/types.ts index e5c07cc..dc174a5 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -159,6 +159,7 @@ export interface Cooldown { account_label: string channel: UpstreamChannel model_id: string + scope: string until: string reason?: string }