From 753b495f8a24653e922ea6e260535fafe2c6ff6d Mon Sep 17 00:00:00 2001 From: nyoungo Date: Wed, 7 Oct 2026 23:16:00 +0800 Subject: [PATCH] feat: console actions to query and reset a site quota --- README.md | 1 + internal/admin/actions.go | 79 +++++++++++++++++++++++++++++++++++++++ internal/admin/web/app.js | 23 +++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7230e1..ea36bce 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Go 实现,编译为**单个静态二进制**,无运行时依赖;内置 **W - **多格式对话接口**:`POST /v1/chat/completions`(OpenAI,流式 / 非流式)、`POST /v1/responses`(OpenAI Responses)、`POST /v1/messages`(Anthropic Messages)、`GET /v1/models`、`GET /health` - **Tool Calling**:三个对话端点均支持 `tools` / `tool_choice`,模型以 JSON 输出 tool_calls,自动回填到响应(`tool_calls` / `function_call` / `tool_use`);回复被上游输出上限截断时自动多轮续写拼接(`continue_rounds`),仍不闭合则在截断处就近补全为部分工具调用返回,由客户端下一轮继续写 - **三站点聚合**:单站点每日额度耗尽自动切换到其他站点的同款模型,额度耗尽的站点临时降级 10 分钟 +- **站点额度管理**:额度按访客身份按日计算,控制台站点卡片可一键查询当前额度、重置访客身份(等同新开隐私窗口、额度即刻回满);请求撞额度时也会自动轮换访客身份恢复,无需重新求解 - **代理线路**:主线路 + 多条备用线路,支持 `http://` `https://` `socks5://`;slow-start 检测 + 失败降级 + 流中失败不重放 - **Turnstile 自动突破**:三种 Token 获取方式 — 求解服务 API(`provider="api"`)、本地浏览器自动求解(`provider="browser"`,CDP 直连 Chrome/Edge,无需 Playwright/Docker)、手动导入 Cookie(`provider="manual"`);结果按「代理线路 × 站点」缓存,TTL 过期自动重解 - **会话自愈**:nonce / data-config 缓存 + 配额耗尽 / 验证失败时自动刷新重试 diff --git a/internal/admin/actions.go b/internal/admin/actions.go index 0369aa5..ca4ac99 100644 --- a/internal/admin/actions.go +++ b/internal/admin/actions.go @@ -382,6 +382,20 @@ func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) { _ = json.Unmarshal(body, &p) writeJSON(w, http.StatusOK, s.probeSite(p.Site)) + case "site_balance": + var p struct { + Site string `json:"site"` + } + _ = json.Unmarshal(body, &p) + writeJSON(w, http.StatusOK, s.siteBalance(p.Site)) + + case "rotate_guest": + var p struct { + Site string `json:"site"` + } + _ = json.Unmarshal(body, &p) + writeJSON(w, http.StatusOK, s.rotateGuest(p.Site)) + default: fail(w, "unknown action: "+req.Action) } @@ -574,6 +588,71 @@ func firstNonEmpty(vals ...string) string { // siteCodes resolves a requested site code to a list; an empty request means // every configured site. +// siteBalance reports the free daily tier of the visitor id currently +// cached for the site (same endpoint the site's own balance badge uses). +func (s *Server) siteBalance(code string) map[string]any { + site, ok, proxy, botID := s.balanceContext(code) + if !ok { + return map[string]any{"ok": false, "error": "site not found"} + } + gid, has := s.ts.GuestID(code, proxy) + if !has { + return map[string]any{"ok": false, "error": "no cached session for this site — send a chat request first"} + } + return s.balanceJSON(code, site, gid, botID, proxy, false) +} + +// rotateGuest swaps the site's visitor cookie for a fresh id — the site +// hands every new visitor id its own daily free tier, the same effect as a +// new private window — then reports the new identity's balance. +func (s *Server) rotateGuest(code string) map[string]any { + site, ok, proxy, botID := s.balanceContext(code) + if !ok { + return map[string]any{"ok": false, "error": "site not found"} + } + gid := s.ts.RotateGuest(code) + if gid == "" { + return map[string]any{"ok": false, "error": "no cached session for this site — refresh cookies first"} + } + return s.balanceJSON(code, site, gid, botID, proxy, true) +} + +func (s *Server) balanceContext(code string) (config.Site, bool, string, int) { + s.cfg.RLock() + defer s.cfg.RUnlock() + st, ok := s.cfg.Sites[code] + if !ok { + return config.Site{}, false, "", 0 + } + botID := 0 + for _, m := range s.cfg.Models { + if m.Site == code && m.Enabled { + botID = m.BotID + break + } + } + return *st, true, s.cfg.Proxy.URL, botID +} + +func (s *Server) balanceJSON(code string, site config.Site, gid string, botID int, proxy string, rotated bool) map[string]any { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + bal, err := s.up.FetchBalance(ctx, site, gid, botID, upstream.Route{Name: "balance", Proxy: proxy}) + if err != nil { + return map[string]any{"ok": false, "error": err.Error()} + } + out := map[string]any{ + "ok": true, "site": code, "rotated": rotated, + "balance": bal.Balance, "limit": bal.Free.Limit, + "used": bal.Free.Used, "remaining": bal.Free.Remaining, + "reset_period": bal.Free.ResetPeriod, + } + if rotated { + s.log.Info("visitor identity rotated from console", "site", code, "remaining", bal.Free.Remaining) + } + return out +} + func (s *Server) siteCodes(want string) []string { s.cfg.RLock() defer s.cfg.RUnlock() diff --git a/internal/admin/web/app.js b/internal/admin/web/app.js index d049e23..f6bd42c 100644 --- a/internal/admin/web/app.js +++ b/internal/admin/web/app.js @@ -178,7 +178,9 @@ PAGES.dashboard = async () => {
${esc(s.code.toUpperCase())} ${s.enabled ? "启用" : "停用"}
${esc(s.base_url)}
-
+
+ +
请求 ${fmtInt(ms ? ms.requests : 0)} · 错误 ${fmtInt(ms ? ms.errors : 0)} · Cookie ${ts.valid ? "有效 " + Math.round(ts.remaining_s / 60) + " 分钟" : "未建立"}
`; @@ -193,6 +195,25 @@ PAGES.dashboard = async () => { b.disabled = false; b.textContent = "探测"; }); + $$("[data-quota]").forEach(b => b.onclick = async () => { + b.disabled = true; b.textContent = "…"; + try { + const r = await api("/api/actions", { body: { action: "site_balance", site: b.dataset.quota } }); + const period = r.reset_period === "daily" ? "每日" : (r.reset_period || ""); + toast(r.ok ? `${b.dataset.quota}: 剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)} · 已用 ${fmtInt(r.used)} · ${period}重置` : `${b.dataset.quota}: ${r.error || "失败"}`, r.ok ? "ok" : "err"); + } catch (e) { toast(e.message, "err"); } + b.disabled = false; b.textContent = "额度"; + }); + + $$("[data-rotate]").forEach(b => b.onclick = async () => { + b.disabled = true; b.textContent = "…"; + try { + const r = await api("/api/actions", { body: { action: "rotate_guest", site: b.dataset.rotate } }); + toast(r.ok ? `${b.dataset.rotate}: 已换新访客身份,剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)}` : `${b.dataset.rotate}: ${r.error || "失败"}`, r.ok ? "ok" : "err"); + } catch (e) { toast(e.message, "err"); } + b.disabled = false; b.textContent = "重置"; + }); + const rec = (m.recent || []).slice(0, 30); $("#tb-recent").innerHTML = rec.length ? rec.map(r => ` ${timeStr(r.at)}