Skip to content

Commit c1b8514

Browse files
committed
「我的站点」下游分析页:分时段/分模型/分用户用量 + 消费预测 (v1.4.0)
- new-api 站点可勾选「这是我自己的中转站」(需管理员令牌),启用专属分析页 - 数据源:new-api 管理员数据看板接口 /api/data/(模型×时间桶)与 /api/data/users(用户×时间桶),非管理员令牌给出明确错误提示 - 页面:期内消费/Tokens/请求数/活跃用户统计卡,用量趋势柱状图(时/天), 分模型 Token 与分用户消费条形图,用户/模型明细表(含占比) - 消费预测(lib/forecast.js):指数加权线性趋势(近期权重 0.92^age)+ 星期因子(满两周启用,识别周末/工作日模式)+ 残差 80% 置信带(随距离增宽), 预测未来 7 天日消费与合计;图上实线历史 + 虚线预测 + 置信带 - 模型行一次拉 35 天同时服务窗口展示与预测底料,服务端缓存 2 分钟 - 内置 mock 补充同款管理员接口(确定性昼夜/周末节奏),演示与测试可用
1 parent 117b862 commit c1b8514

9 files changed

Lines changed: 518 additions & 4 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
- **余额预测**:记录余额历史(30 天),线性回归估算日均消耗与预计耗尽时间;点击任意站点查看趋势图(历史折线 + 虚线耗尽投影 + 悬停查看);识别充值,只按最近一段消耗回归
2020
- **通知告警**:余额偏低 / 耗尽 / 查询失败 / 恢复正常 / 预计即将耗尽(**阈值可按天或小时设置**),状态迁移触发、自动去重、可配重复提醒间隔;支持 10 种渠道:
2121
Telegram、钉钉(含加签)、企业微信、飞书(含签名)、Bark、ntfy、Server酱(含 sctp 新版)、Resend 邮件、SMTP 邮件(内置零依赖客户端,465 SSL / 587 STARTTLS)、自定义 Webhook,每个渠道可单独测试
22+
- **我的站点(下游分析)**:自己经营 new-api 中转站时,勾选「这是我自己的中转站」
23+
(需管理员 root 账号的系统访问令牌),即可获得专属分析页——分时段 / 分模型 / **分用户**
24+
的用量与消费(数据来自 new-api 管理员接口 `/api/data/``/api/data/users`),
25+
以及**未来 7 天消费预测**(加权线性趋势 + 星期因子,附 80% 置信区间;历史满两周自动
26+
启用周末/工作日模式识别)
2227
- **人民币折算**:每个站点可配置充值汇率(站点 $1 折合 ¥ 多少,如 1:2 充值);
2328
余额、消耗、图表等金额主显人民币(未配置按 1:1),站点原始余额作为次要信息展示;
2429
**余额告警阈值仍按站点余额判断**,不受汇率影响

lib/forecast.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// 日消费预测:加权线性趋势 + 星期因子 + 残差置信带
2+
//
3+
// 方法(可解释优先,不上黑盒):
4+
// 1. 数据满两周时计算星期因子(周末/工作日消费模式差异),先去季节化
5+
// 2. 对去季节序列做指数加权最小二乘(近期权重大,w = 0.92^age),
6+
// 拟合线性趋势 y = a + b·i
7+
// 3. 预测值 = 趋势外推 × 当天星期因子;置信带用加权残差标准差的
8+
// ±1.28σ(约 80% 区间),随预测距离增宽
9+
10+
const r2 = (v) => Math.round(v * 100) / 100;
11+
12+
/**
13+
* @param daily [{t: 当日零点 ms, cost: 当日消费}] 升序、无缺日(缺日补 0)
14+
* @param horizon 预测天数
15+
* @returns { points: [{t, cost, lo, hi}], nextTotal, method, sampleDays } 或 null(数据不足)
16+
*/
17+
export function forecastDaily(daily, horizon = 7) {
18+
const n = daily.length;
19+
if (n < 3) return null;
20+
21+
const vals = daily.map((d) => d.cost);
22+
23+
// 星期因子(不足两周不启用,避免小样本过拟合)
24+
let factors = Array(7).fill(1);
25+
if (n >= 14) {
26+
const sum = Array(7).fill(0), cnt = Array(7).fill(0);
27+
for (const d of daily) {
28+
const w = new Date(d.t).getDay();
29+
sum[w] += d.cost; cnt[w]++;
30+
}
31+
const overall = vals.reduce((a, b) => a + b, 0) / n;
32+
if (overall > 0) {
33+
factors = sum.map((s, i) => {
34+
const f = cnt[i] ? s / cnt[i] / overall : 1;
35+
return Math.min(3, Math.max(0.3, f || 1)); // 极端因子截断
36+
});
37+
}
38+
}
39+
40+
// 去季节 + 指数加权线性回归
41+
const adj = daily.map((d) => d.cost / factors[new Date(d.t).getDay()]);
42+
let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0;
43+
adj.forEach((y, i) => {
44+
const w = Math.pow(0.92, n - 1 - i);
45+
sw += w; swx += w * i; swy += w * y; swxx += w * i * i; swxy += w * i * y;
46+
});
47+
const denom = sw * swxx - swx * swx;
48+
const b = Math.abs(denom) > 1e-9 ? (sw * swxy - swx * swy) / denom : 0;
49+
const a = (swy - b * swx) / sw;
50+
51+
let rss = 0;
52+
adj.forEach((y, i) => {
53+
const e = y - (a + b * i);
54+
rss += Math.pow(0.92, n - 1 - i) * e * e;
55+
});
56+
const sigma = Math.sqrt(rss / sw);
57+
58+
const lastT = daily[n - 1].t;
59+
const points = [];
60+
for (let k = 1; k <= horizon; k++) {
61+
const t = lastT + k * 86400000;
62+
const f = factors[new Date(t).getDay()];
63+
const base = Math.max(0, (a + b * (n - 1 + k)) * f);
64+
const spread = 1.28 * sigma * f * Math.sqrt(1 + k * 0.15); // 越远越不确定
65+
points.push({ t, cost: r2(base), lo: r2(Math.max(0, base - spread)), hi: r2(base + spread) });
66+
}
67+
return {
68+
points,
69+
nextTotal: r2(points.reduce((x, p) => x + p.cost, 0)),
70+
method: n >= 14 ? "加权线性趋势 + 星期因子" : "加权线性趋势",
71+
sampleDays: n,
72+
};
73+
}

lib/providers.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ async function querySub2Api(station) {
285285
// ---- 用量明细(分模型 / 分时间)--------------------------------------------
286286

287287
// 指定时区下某时刻的 YYYY-MM-DD(en-CA 的日期格式正好是这个)
288-
function dateStrInTz(ms, tz) {
288+
export function dateStrInTz(ms, tz) {
289289
return new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(new Date(ms));
290290
}
291291

@@ -302,7 +302,7 @@ function tzOffsetMs(tz, at) {
302302
}
303303

304304
// 把「2026-07-13 05:00」这类无时区标签按指定时区解析成时间戳;失败返回 null
305-
function parseDateLabel(s, tz) {
305+
export function parseDateLabel(s, tz) {
306306
if (!s) return null;
307307
const utc = Date.parse(String(s).replace(" ", "T") + (String(s).length <= 10 ? "T00:00:00Z" : "Z"));
308308
if (Number.isNaN(utc)) return null;
@@ -475,6 +475,40 @@ async function newApiUsage(station, { startMs, endMs }) {
475475
};
476476
}
477477

478+
/**
479+
* 「我的站点」下游数据:new-api 管理员数据看板接口。
480+
* kind = "model" → GET /api/data/ 行按 (model_name, created_at) 聚合
481+
* kind = "user" → GET /api/data/users 行按 (username, created_at) 聚合
482+
* 返回归一化行 [{key, t(ms), tokens, cost($), requests}]
483+
*/
484+
export async function queryOwnData(station, startMs, endMs, kind) {
485+
const base = trimBase(station.baseUrl);
486+
const token = String(station.accessToken || "").trim();
487+
if (!base || !token) throw new Error("缺少站点地址或访问令牌");
488+
const headers = { Authorization: token };
489+
const userId = String(station.userId || "").trim();
490+
if (userId) headers["New-Api-User"] = userId;
491+
492+
const path = kind === "user" ? "/api/data/users" : "/api/data/";
493+
const qs = `start_timestamp=${Math.floor(startMs / 1000)}&end_timestamp=${Math.ceil(endMs / 1000)}`;
494+
const r = await request(`${base}${path}?${qs}`, { headers, timeoutMs: 20000 });
495+
if (r.status >= 300) throw new Error(httpErrorMessage(r));
496+
if (r.body?.success === false) {
497+
const msg = String(r.body?.message || "");
498+
throw new Error(/privileg|unauthorized/i.test(msg)
499+
? "该令牌没有管理员权限:请使用管理员(root)账号的系统访问令牌与用户 ID"
500+
: msg || "查询失败");
501+
}
502+
const rows = Array.isArray(r.body?.data) ? r.body.data : [];
503+
return rows.map((row) => ({
504+
key: (kind === "user" ? row.username : row.model_name) || "unknown",
505+
t: num(row.created_at) * 1000,
506+
tokens: num(row.token_used),
507+
cost: num(row.quota) / QUOTA_PER_UNIT,
508+
requests: num(row.count),
509+
}));
510+
}
511+
478512
/**
479513
* 查询站点的用量明细(分模型 + 分时间),返回 {models, trend}。
480514
* 与余额查询不同,出错直接 throw,由调用方汇总每个站点的错误。

lib/store.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ export class Store {
179179
lowBalanceUsd: numOrNull(input.lowBalanceUsd),
180180
// 充值折算汇率:站点 $1 折合人民币(¥);null 按 1:1 展示
181181
cnyPerUsd: numOrNull(input.cnyPerUsd),
182+
// 我自己的中转站:启用「我的站点」下游用量分析(需管理员令牌)
183+
isOwn: !!input.isOwn,
182184
demo: !!input.demo,
183185
createdAt: new Date().toISOString(),
184186
s2Tokens: null, // Sub2API 密码模式的令牌缓存 {accessToken, refreshToken, expiresAt}
@@ -202,6 +204,7 @@ export class Store {
202204
if ("password" in patch) s.password = String(patch.password ?? "");
203205
if ("lowBalanceUsd" in patch) s.lowBalanceUsd = numOrNull(patch.lowBalanceUsd);
204206
if ("cnyPerUsd" in patch) s.cnyPerUsd = numOrNull(patch.cnyPerUsd);
207+
if ("isOwn" in patch) s.isOwn = !!patch.isOwn;
205208
// 凭证或站点实际变化才作废令牌缓存(前端编辑总会带上 type/email 原值,
206209
// 无脑作废会导致每次改名都触发一次完整重登录)
207210
const credsChanged =

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "relay-monitor",
3-
"version": "1.3.1",
3+
"version": "1.4.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

0 commit comments

Comments
 (0)