feat: console actions to query and reset a site quota

This commit is contained in:
nyoungo committed 2026-10-07 23:16:00 +08:00
1 parent 08464a27d7
commit 753b495f8a
3 files changed
+102 -1

No files matched your search

+1
View File
@@ -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 缓存 + 配额耗尽 / 验证失败时自动刷新重试
+79
View File
@@ -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()
+22 -1
View File
@@ -178,7 +178,9 @@ PAGES.dashboard = async () => {
<div class="toolbar">
<div><b>${esc(s.code.toUpperCase())}</b> <span class="pill ${s.enabled ? "on" : "off"}">${s.enabled ? "启用" : "停用"}</span>
<div class="dim">${esc(s.base_url)}</div></div>
<div class="right"><button class="btn small" data-probe="${esc(s.code)}">探测</button></div>
<div class="right"><button class="btn small" data-probe="${esc(s.code)}">探测</button>
<button class="btn small" data-quota="${esc(s.code)}">额度</button>
<button class="btn small" data-rotate="${esc(s.code)}">重置</button></div>
</div>
<div class="dim" style="margin-top:8px">请求 ${fmtInt(ms ? ms.requests : 0)} · 错误 ${fmtInt(ms ? ms.errors : 0)} · Cookie ${ts.valid ? "有效 " + Math.round(ts.remaining_s / 60) + " 分钟" : "未建立"}</div>
<div class="bar"><i style="width:${pct}%"></i></div></div>`;
@@ -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 => `
<tr><td class="dim">${timeStr(r.at)}</td>