Skip to content

Commit 90b7c97

Browse files
committed
独立用量统计页:分站点 / 分模型 / 分时段的 Token 消耗 (v1.2.0 预备)
- providers 新增 queryStationUsage:Sub2API 走 /usage/dashboard/snapshot-v2 (老版本回退 trend + models 双接口,密码模式复用令牌缓存并自动续期); New API 走 /api/data/self 数据看板接口,quota 按 500000=$1 换算 - GET /api/usage?range=today|7d|30d:并行查询全部站点,按范围缓存 60 秒, 单站失败不影响整体(错误逐站展示) - 新导航「用量统计」:范围/站点筛选,总 Tokens / 实际消耗 / 请求数 / 数据来源统计卡, 按小时/天的消耗趋势柱状图 + 分模型条形图(超 10 项折叠为其他)+ 模型明细表 - Docker 镜像加 tzdata,compose 提供 TZ=Asia/Shanghai(今日边界按该时区); Watchtower 检查间隔缩短到 60 秒
1 parent 85924c0 commit 90b7c97

8 files changed

Lines changed: 452 additions & 3 deletions

File tree

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ ENV NODE_ENV=production \
66
HOST=0.0.0.0 \
77
PORT=8787
88

9+
# 支持 TZ 环境变量(「今日」统计边界按此时区计算)
10+
RUN apk add --no-cache tzdata
11+
912
COPY package.json package-lock.json ./
1013
RUN npm ci --omit=dev
1114

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99

1010
- **总览面板**:总剩余余额、**今日总消耗**、日均消耗(估算)、低余额 / 耗尽、查询异常统计;**总余额趋势图**(全站合计、24 小时~30 天切换、悬停查看分站明细)与**日均消耗对比图**;每站余额、今日消耗、用量进度条、状态标签、查询延迟
1111
- **今日消耗与站点一致**:Sub2API 站点直接读取站点用户仪表盘同款接口(`/api/v1/usage/dashboard/stats``today_actual_cost`,即今日实际扣费),与站点页面显示的数值完全一致;其他类型按余额历史推算并以 ≈ 标注
12+
- **用量统计页**:分站点、分模型、分时段的 Token 消耗——今天(按小时)/ 近 7 天 / 近 30 天,
13+
含消耗趋势柱状图、分模型条形图与明细表(请求数 / 输入输出 / 总 Tokens / 实际消耗)。
14+
数据源:Sub2API 用 `/usage/dashboard/snapshot-v2`(老版本自动回退 trend + models 接口),
15+
New API 用 `/api/data/self`(需站点开启数据看板);sk 密钥模式无用量明细接口
1216
- **Sub2API 账号密码模式**:只填邮箱 + 密码,面板自动登录换取令牌;令牌过期时自动用 refresh_token 刷新(支持轮换),刷新失败自动用密码重新登录——**全程无需人工干预**
1317
- **余额预测**:记录余额历史(30 天),线性回归估算日均消耗与预计耗尽时间;点击任意站点查看趋势图(历史折线 + 虚线耗尽投影 + 悬停查看);识别充值,只按最近一段消耗回归
1418
- **通知告警**:余额偏低 / 耗尽 / 查询失败 / 恢复正常 / 预计即将耗尽(**阈值可按天或小时设置**),状态迁移触发、自动去重、可配重复提醒间隔;支持 10 种渠道:

deploy/docker-compose.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ services:
99
- "8787:8787"
1010
environment:
1111
- HOST=0.0.0.0
12+
# 「今日消耗 / 用量统计」的日期边界按此时区计算
13+
- TZ=Asia/Shanghai
1214
volumes:
1315
- ./data:/app/data
1416

@@ -24,5 +26,5 @@ services:
2426
environment:
2527
# 新版 Docker 守护进程要求 API >= 1.40,watchtower 默认协商值过老
2628
- DOCKER_API_VERSION=1.44
27-
# 每 5 分钟检查一次 relay-monitor 的新镜像,更新后清理旧镜像
28-
command: --cleanup --interval 300 relay-monitor
29+
# 每分钟检查一次 relay-monitor 的新镜像,更新后清理旧镜像
30+
command: --cleanup --interval 60 relay-monitor

lib/providers.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,151 @@ async function querySub2Api(station) {
282282
return { ...parseSub2ApiMe(data), todayUsed, todayRequests, todayTokens, tokensChanged };
283283
}
284284

285+
// ---- 用量明细(分模型 / 分时间)--------------------------------------------
286+
287+
function localDateStr(ms) {
288+
const d = new Date(ms);
289+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
290+
}
291+
292+
// 尝试把提供商返回的时间标签解析成时间戳;解析失败返回 null,前端按原始标签展示
293+
function parseDateLabel(s) {
294+
if (!s) return null;
295+
let t = Date.parse(s);
296+
if (Number.isNaN(t)) t = Date.parse(String(s).replace(" ", "T"));
297+
return Number.isNaN(t) ? null : t;
298+
}
299+
300+
// 取可用的 Sub2API bearer;密码模式沿用 s2Tokens 缓存并自动登录/刷新
301+
async function sub2apiBearer(station, base, force = false) {
302+
const passwordMode = station.type === "sub2api-password" || station.authMode === "password";
303+
if (!passwordMode) {
304+
const t = String(station.accessToken || station.apiKey || "").trim();
305+
if (!t) throw new Error("缺少登录令牌 (Sub2API JWT)");
306+
return t;
307+
}
308+
const tokens = (station.s2Tokens ||= {});
309+
if (!force && tokens.accessToken && tokens.expiresAt &&
310+
tokens.expiresAt - Date.now() > TOKEN_REFRESH_BUFFER_MS) {
311+
return tokens.accessToken;
312+
}
313+
if (force) tokens.accessToken = "";
314+
if (!(await sub2apiRefresh(base, tokens))) await sub2apiLogin(base, station, tokens);
315+
return tokens.accessToken;
316+
}
317+
318+
function normSub2Models(models) {
319+
return (models || []).map((m) => ({
320+
model: m.model || "unknown",
321+
tokens: num(m.total_tokens),
322+
cost: num(m.actual_cost ?? m.cost),
323+
requests: num(m.requests),
324+
inputTokens: num(m.input_tokens),
325+
outputTokens: num(m.output_tokens),
326+
})).sort((a, b) => b.tokens - a.tokens);
327+
}
328+
329+
function normSub2Trend(trend) {
330+
return (trend || []).map((p) => ({
331+
t: parseDateLabel(p.date),
332+
label: String(p.date ?? ""),
333+
tokens: num(p.total_tokens),
334+
cost: num(p.actual_cost ?? p.cost),
335+
requests: num(p.requests),
336+
}));
337+
}
338+
339+
async function sub2apiUsage(station, { startMs, endMs, granularity, tz }) {
340+
const base = trimBase(station.baseUrl);
341+
if (!base) throw new Error("缺少站点地址");
342+
const params = {
343+
start_date: localDateStr(startMs),
344+
end_date: localDateStr(endMs),
345+
granularity,
346+
timezone: tz,
347+
};
348+
const get = async (path, extra) => {
349+
let bearer = await sub2apiBearer(station, base);
350+
let r = await request(`${base}${path}?${new URLSearchParams({ ...params, ...extra })}`, {
351+
headers: { Authorization: `Bearer ${bearer}` },
352+
});
353+
if (r.status === 401 && station.type === "sub2api-password") {
354+
bearer = await sub2apiBearer(station, base, true);
355+
r = await request(`${base}${path}?${new URLSearchParams({ ...params, ...extra })}`, {
356+
headers: { Authorization: `Bearer ${bearer}` },
357+
});
358+
}
359+
return r;
360+
};
361+
362+
// 首选 snapshot-v2 一次拿全;老版本站点回退到 trend + models 两个接口
363+
const snap = await get("/api/v1/usage/dashboard/snapshot-v2", {
364+
include_trend: "true", include_model_stats: "true",
365+
});
366+
if (snap.status < 300 && snap.body?.code === 0 && snap.body.data) {
367+
const d = snap.body.data;
368+
return { models: normSub2Models(d.models), trend: normSub2Trend(d.trend) };
369+
}
370+
const [tr, mo] = await Promise.all([
371+
get("/api/v1/usage/dashboard/trend"),
372+
get("/api/v1/usage/dashboard/models"),
373+
]);
374+
if (tr.status >= 300 && mo.status >= 300) throw new Error(httpErrorMessage(tr));
375+
return {
376+
models: mo.status < 300 && mo.body?.code === 0 ? normSub2Models(mo.body.data?.models) : [],
377+
trend: tr.status < 300 && tr.body?.code === 0 ? normSub2Trend(tr.body.data?.trend) : [],
378+
};
379+
}
380+
381+
async function newApiUsage(station, { startMs, endMs }) {
382+
const base = trimBase(station.baseUrl);
383+
const token = String(station.accessToken || "").trim();
384+
if (!base || !token) throw new Error("缺少站点地址或访问令牌");
385+
const headers = { Authorization: token };
386+
const userId = String(station.userId || "").trim();
387+
if (userId) headers["New-Api-User"] = userId;
388+
389+
const qs = `start_timestamp=${Math.floor(startMs / 1000)}&end_timestamp=${Math.ceil(endMs / 1000)}`;
390+
const r = await request(`${base}/api/data/self?${qs}`, { headers });
391+
if (r.status >= 300) throw new Error(httpErrorMessage(r));
392+
if (r.body?.success === false) throw new Error(r.body?.message || "站点未开启数据看板");
393+
const rows = Array.isArray(r.body?.data) ? r.body.data : null;
394+
if (!rows) throw new Error("站点未开启数据看板(DataExportEnabled)");
395+
396+
const models = new Map(), trend = new Map();
397+
for (const row of rows) {
398+
const tokens = num(row.token_used);
399+
const cost = num(row.quota) / QUOTA_PER_UNIT;
400+
const reqs = num(row.count);
401+
const name = row.model_name || "unknown";
402+
const m = models.get(name) || { model: name, tokens: 0, cost: 0, requests: 0, inputTokens: null, outputTokens: null };
403+
m.tokens += tokens; m.cost += cost; m.requests += reqs;
404+
models.set(name, m);
405+
const t = num(row.created_at) * 1000;
406+
const tr = trend.get(t) || { t, label: "", tokens: 0, cost: 0, requests: 0 };
407+
tr.tokens += tokens; tr.cost += cost; tr.requests += reqs;
408+
trend.set(t, tr);
409+
}
410+
return {
411+
models: [...models.values()].sort((a, b) => b.tokens - a.tokens),
412+
trend: [...trend.values()].sort((a, b) => a.t - b.t),
413+
};
414+
}
415+
416+
/**
417+
* 查询站点的用量明细(分模型 + 分时间),返回 {models, trend}。
418+
* 与余额查询不同,出错直接 throw,由调用方汇总每个站点的错误。
419+
*/
420+
export async function queryStationUsage(station, opts) {
421+
switch (station.type) {
422+
case "newapi": return newApiUsage(station, opts);
423+
case "newapi-key": throw new Error("sk 密钥模式没有用量明细接口");
424+
case "sub2api":
425+
case "sub2api-password": return sub2apiUsage(station, opts);
426+
default: throw new Error(`未知的中转站类型:${station.type}`);
427+
}
428+
}
429+
285430
const HANDLERS = {
286431
newapi: queryNewApi,
287432
"newapi-key": queryNewApiKey,

0 commit comments

Comments
 (0)