Skip to content

Commit 19dcbd4

Browse files
committed
收入剔除管理员/root 消费;新增用户余额展示 (v1.7.0)
- 拉取自有站用户列表(/api/user/ 管理员接口,分页,缓存 10 分钟), role >= 10(管理员/root)识别为内部账号 - 利润口径修正:收入 = 普通用户期内消费 × 售价汇率;管理员自用 不产生收入但上游成本照付,收入卡片注明「管理员另消耗 ¥X(计成本不计收入)」 - 用户明细表中管理员行加标签,用量数据保持完整可见 - 新增「用户余额」表(不含管理员):非零余额降序 + 累计已用 + 状态, 零余额用户折叠为计数,表头显示余额合计 - 利润仍按「期内消费 − 成本」口径(非余额差值)
1 parent 25de12b commit 19dcbd4

5 files changed

Lines changed: 113 additions & 11 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
以及**未来 7 天消费预测**(加权线性趋势 + 星期因子,附 80% 置信区间;历史满两周自动
2626
启用周末/工作日模式识别)
2727
- **利润分析**:读取自有站的渠道列表,按 base_url 与监控中的上游站点匹配(忽略协议 /
28-
末尾斜杠 / `/api` 后缀,同 URL 渠道合并)——利润 = 下游收入(消费 × 你的售价汇率)−
28+
末尾斜杠 / `/api` 后缀,同 URL 渠道合并)——利润 = 下游收入(**普通用户**消费 ×
29+
你的售价汇率,管理员/root 自用计成本不计收入)−
2930
各匹配上游的期内成本(按用量 × 充值汇率;「固定成本」渠道按天摊销:
3031
每次付费金额 ÷ 覆盖天数 × 窗口天数,且无论是否匹配到渠道都计入;
3132
用量接口不可用时退回余额下降推算)。未匹配的渠道单独列出,加入监控即可参与计算

lib/providers.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,43 @@ export async function queryOwnChannels(station) {
545545
return out;
546546
}
547547

548+
/**
549+
* 「我的站点」用户列表(new-api 管理员接口,分页拉全)。
550+
* 返回 [{id, username, displayName, role, status, quotaUsd, usedUsd}]
551+
* role: 1=普通用户 10=管理员 100=root;quota 按 500000=$1 换算
552+
*/
553+
export async function queryOwnUsers(station) {
554+
const base = trimBase(station.baseUrl);
555+
const token = String(station.accessToken || "").trim();
556+
if (!base || !token) throw new Error("缺少站点地址或访问令牌");
557+
const headers = { Authorization: token };
558+
const userId = String(station.userId || "").trim();
559+
if (userId) headers["New-Api-User"] = userId;
560+
561+
const out = [];
562+
for (let p = 1; p <= 10; p++) {
563+
const r = await request(`${base}/api/user/?p=${p}&page_size=100`, { headers, timeoutMs: 15000 });
564+
if (r.status >= 300) throw new Error(httpErrorMessage(r));
565+
if (r.body?.success === false) throw new Error(r.body?.message || "获取用户列表失败");
566+
const items = Array.isArray(r.body?.data) ? r.body.data
567+
: Array.isArray(r.body?.data?.items) ? r.body.data.items : [];
568+
for (const u of items) {
569+
out.push({
570+
id: u.id,
571+
username: String(u.username || ""),
572+
displayName: String(u.display_name || ""),
573+
role: Number(u.role) || 1,
574+
status: Number(u.status) || 0,
575+
quotaUsd: num(u.quota) / QUOTA_PER_UNIT,
576+
usedUsd: num(u.used_quota) / QUOTA_PER_UNIT,
577+
});
578+
}
579+
const total = Number(r.body?.data?.total);
580+
if (!items.length || !Number.isFinite(total) || out.length >= total) break;
581+
}
582+
return out;
583+
}
584+
548585
/**
549586
* 查询站点的用量明细(分模型 + 分时间),返回 {models, trend}。
550587
* 与余额查询不同,出错直接 throw,由调用方汇总每个站点的错误。

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.6.1",
3+
"version": "1.7.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -960,14 +960,15 @@ function renderOwnBody() {
960960
<table class="u-table">
961961
<thead><tr><th>用户</th><th>请求数</th><th>Tokens</th><th>消费</th><th>占比</th></tr></thead>
962962
<tbody>${users.map((u) => `<tr>
963-
<td class="mono">${esc(u.user)}</td>
963+
<td class="mono">${esc(u.user)}${u.isAdmin ? ' <span class="demo-tag tag-warn">管理员</span>' : ""}</td>
964964
<td>${u.requests.toLocaleString("en-US")}</td>
965965
<td>${u.tokens.toLocaleString("en-US")}</td>
966966
<td>${cny4(u.cost)}</td>
967967
<td>${totCost > 0 ? (u.cost / totCost * 100).toFixed(1) + "%" : "—"}</td>
968968
</tr>`).join("") || '<tr><td colspan="5" class="u-empty">该范围内暂无数据</td></tr>'}</tbody>
969969
</table>
970970
</div>
971+
${userBalanceSection(d.userBalances, rate)}
971972
<div class="section-head" style="margin-top:16px"><h2>模型明细</h2><span class="muted">共 ${models.length} 个模型</span></div>
972973
<div class="panel u-table-wrap">
973974
<table class="u-table">
@@ -989,6 +990,31 @@ function renderOwnBody() {
989990
fc ? fc.points.map((p) => ({ ...p, cost: p.cost * rate, lo: p.lo * rate, hi: p.hi * rate })) : null);
990991
}
991992

993+
// 用户余额(不含管理员/root):非零余额排序展示,零余额只报数量
994+
function userBalanceSection(list, rate) {
995+
if (!list) return "";
996+
const nonZero = list.filter((u) => u.balanceUsd > 0.0001);
997+
const zeroCount = list.length - nonZero.length;
998+
const totalCny = list.reduce((a, u) => a + u.balanceUsd, 0) * rate;
999+
return `
1000+
<div class="section-head" style="margin-top:16px"><h2>用户余额</h2>
1001+
<span class="muted">不含管理员 · 共 ${list.length} 个用户 · 余额合计 ${cny(totalCny)}</span></div>
1002+
<div class="panel u-table-wrap">
1003+
<table class="u-table">
1004+
<thead><tr><th>用户</th><th>余额</th><th>累计已用</th><th>状态</th></tr></thead>
1005+
<tbody>
1006+
${nonZero.map((u) => `<tr>
1007+
<td class="mono">${esc(u.user)}</td>
1008+
<td>${cny4(u.balanceUsd * rate)}</td>
1009+
<td>${cny(u.usedUsd * rate)}</td>
1010+
<td>${u.status === 1 ? "正常" : '<span class="muted">已禁用</span>'}</td>
1011+
</tr>`).join("") || '<tr><td colspan="4" class="u-empty">没有余额大于 0 的用户</td></tr>'}
1012+
${zeroCount > 0 ? `<tr><td colspan="4" class="u-empty">另有 ${zeroCount} 个用户余额为 0</td></tr>` : ""}
1013+
</tbody>
1014+
</table>
1015+
</div>`;
1016+
}
1017+
9921018
// 利润区块:收入 − 匹配上游的成本;未匹配渠道单独列出
9931019
function profitSection(p) {
9941020
if (!p) return "";
@@ -1020,9 +1046,11 @@ function profitSection(p) {
10201046
</div>`).join("")}</div>` : "";
10211047
return `
10221048
<div class="section-head"><h2>利润分析</h2>
1023-
<span class="muted">收入按你的售价汇率 · 成本按各上游充值汇率(窗口 ${p.windowDays} 天)</span></div>
1049+
<span class="muted">收入 = 普通用户消费 × 售价汇率(不含管理员/root)· 成本按各上游口径(窗口 ${p.windowDays} 天)</span></div>
10241050
<div class="stats" style="grid-template-columns:repeat(4,1fr)">
1025-
<div class="stat-card"><div class="label">期内收入</div><div class="value">${cny(p.incomeCny)}</div></div>
1051+
<div class="stat-card"><div class="label">期内收入</div><div class="value">${cny(p.incomeCny)}</div>${
1052+
p.adminUsageCny > 0 ? `<div class="stat-sub">管理员另消耗 ${cny(p.adminUsageCny)}(计成本不计收入)</div>` : ""
1053+
}</div>
10261054
<div class="stat-card"><div class="label">期内成本</div><div class="value">${cny(p.totalCostCny)}</div></div>
10271055
<div class="stat-card"><div class="label">利润</div><div class="value ${profitCls}">${cny(p.profitCny)}</div></div>
10281056
<div class="stat-card"><div class="label">利润率</div><div class="value ${profitCls}">${p.marginPct != null ? p.marginPct + "%" : "—"}</div></div>

server.js

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { readFile } from "node:fs/promises";
55

66
import { Store } from "./lib/store.js";
77
import {
8-
queryStation, queryStationUsage, queryOwnData, queryOwnChannels,
8+
queryStation, queryStationUsage, queryOwnData, queryOwnChannels, queryOwnUsers,
99
dateStrInTz, parseDateLabel, dailyFixedCny, STATION_TYPES,
1010
} from "./lib/providers.js";
1111
import { forecastDaily } from "./lib/forecast.js";
@@ -182,6 +182,19 @@ mock.get("/newapi/:acc/api/data/users", (req, res) => {
182182
if (!needAuth(req, res)) return;
183183
res.json({ success: true, message: "", data: mockOwnRows(Number(req.query.start_timestamp) || 0, Number(req.query.end_timestamp) || 0, "user") });
184184
});
185+
mock.get("/newapi/:acc/api/user/", (req, res) => {
186+
if (!needAuth(req, res)) return;
187+
res.json({
188+
success: true, message: "",
189+
data: { items: [
190+
{ id: 1, username: "root", display_name: "Root", role: 100, status: 1, quota: 99500000, used_quota: 4200000 },
191+
{ id: 2, username: "alice", display_name: "", role: 1, status: 1, quota: 5250000, used_quota: 61500000 },
192+
{ id: 3, username: "bob", display_name: "", role: 1, status: 1, quota: 1200000, used_quota: 38200000 },
193+
{ id: 4, username: "carol", display_name: "", role: 1, status: 1, quota: 0, used_quota: 17800000 },
194+
{ id: 5, username: "dave", display_name: "", role: 10, status: 1, quota: 800000, used_quota: 9200000 },
195+
], total: 5 },
196+
});
197+
});
185198
mock.get("/newapi/:acc/api/channel/", (req, res) => {
186199
if (!needAuth(req, res)) return;
187200
const local = `http://${HOST}:${PORT}/mock`;
@@ -534,16 +547,33 @@ app.get("/api/own/analytics", async (req, res) => {
534547
daily.push({ t, cost: Math.round((dmap.get(t) || 0) * 10000) / 10000 });
535548
}
536549
}
537-
const byUser = aggBy(userRows, "user");
550+
// 用户列表:识别管理员(role >= 10)并取各用户余额
551+
let ownUsers = null;
552+
try {
553+
ownUsers = await getOwnUsers(own);
554+
} catch { /* 拿不到就退化为不区分管理员 */ }
555+
const adminSet = new Set((ownUsers || []).filter((u) => u.role >= 10).map((u) => u.username));
556+
557+
const byUser = aggBy(userRows, "user").map((u) => ({ ...u, isAdmin: adminSet.has(u.user) }));
558+
// 收入 = 普通用户的期内消费;管理员/root 自己用不产生收入,但上游成本照付
559+
const incomeUsd = byUser.filter((u) => !u.isAdmin).reduce((a, u) => a + u.cost, 0);
560+
const adminUsageUsd = byUser.filter((u) => u.isAdmin).reduce((a, u) => a + u.cost, 0);
561+
538562
const payload = {
539563
range, tz, startMs, endMs: now,
540564
station: { id: own.id, name: own.name, cnyPerUsd: own.cnyPerUsd ?? null },
541565
byModel: aggBy(winModel, "model"),
542566
byUser,
567+
userBalances: ownUsers
568+
? ownUsers
569+
.filter((u) => u.role < 10)
570+
.map((u) => ({ user: u.username, balanceUsd: Math.round(u.quotaUsd * 10000) / 10000, usedUsd: Math.round(u.usedUsd * 100) / 100, status: u.status }))
571+
.sort((a, b) => b.balanceUsd - a.balanceUsd)
572+
: null,
543573
trend: [...tmap.values()].sort((a, b) => a.t - b.t),
544574
daily: daily.slice(-14),
545575
forecast: forecastDaily(daily, 7),
546-
profit: await computeProfit(own, payloadIncome(winModel), { startMs, now, tz, range }),
576+
profit: await computeProfit(own, incomeUsd, adminUsageUsd, { startMs, now, tz, range }),
547577
generatedAt: new Date().toISOString(),
548578
};
549579
ownCache.set(cacheKey, { at: Date.now(), payload });
@@ -553,8 +583,13 @@ app.get("/api/own/analytics", async (req, res) => {
553583
}
554584
});
555585

556-
function payloadIncome(winModelRows) {
557-
return winModelRows.reduce((a, r) => a + r.cost, 0);
586+
// 用户列表拉取开销不小,缓存 10 分钟
587+
let ownUsersCache = { at: 0, stationId: null, list: null };
588+
async function getOwnUsers(own) {
589+
if (!ownUsersCache.list || ownUsersCache.stationId !== own.id || Date.now() - ownUsersCache.at > 600000) {
590+
ownUsersCache = { at: Date.now(), stationId: own.id, list: await queryOwnUsers(own) };
591+
}
592+
return ownUsersCache.list;
558593
}
559594

560595
// 渠道列表拉取开销不小,缓存 10 分钟
@@ -566,7 +601,7 @@ let ownChannelsCache = { at: 0, stationId: null, list: null };
566601
* 否则按上游用量接口的实际扣费 × 充值汇率;用量接口不可用时退回余额下降推算。
567602
* 渠道按 base_url 与监控站点匹配(忽略协议/末尾斜杠/是否带 /api)。
568603
*/
569-
async function computeProfit(own, incomeUsd, { startMs, now, tz, range }) {
604+
async function computeProfit(own, incomeUsd, adminUsageUsd, { startMs, now, tz, range }) {
570605
const r2 = (v) => Math.round(v * 100) / 100;
571606
try {
572607
if (!ownChannelsCache.list || ownChannelsCache.stationId !== own.id || Date.now() - ownChannelsCache.at > 600000) {
@@ -646,6 +681,7 @@ async function computeProfit(own, incomeUsd, { startMs, now, tz, range }) {
646681
const totalCostCny = r2(costs.reduce((a, c) => a + c.cny, 0));
647682
return {
648683
incomeCny, totalCostCny,
684+
adminUsageCny: r2((adminUsageUsd || 0) * ownRate),
649685
profitCny: r2(incomeCny - totalCostCny),
650686
marginPct: incomeCny > 0 ? Math.round(((incomeCny - totalCostCny) / incomeCny) * 1000) / 10 : null,
651687
costs: costs.sort((a, b) => b.cny - a.cny),

0 commit comments

Comments
 (0)