Skip to content

Commit a0beb23

Browse files
committed
利润分析:下游收入 − 上游成本,渠道 URL 匹配 + 固定成本摊销 (v1.5.0)
- 读取自有站渠道列表(/api/channel/ 管理员接口,分页拉全,缓存 10 分钟), 按 base_url 与监控站点匹配:忽略协议/末尾斜杠/是否带 /api,同 URL 渠道合并 - 利润 = 期内收入(下游消费 × 自有站售价汇率)− 各匹配上游期内成本; 成本三种口径并在界面标注:按用量(上游用量接口实际扣费 × 充值汇率)/ 固定摊销(新增「每月固定成本」字段,月费 ÷ 30 × 窗口天数,适合包月上游)/ 余额推算(用量接口不可用时按余额下降兜底) - 未匹配渠道合并列出(含启用数),提示加入监控即可参与利润计算 - 利润/利润率盈利绿、亏损红;mock 补渠道接口
1 parent c1b8514 commit a0beb23

8 files changed

Lines changed: 190 additions & 2 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
的用量与消费(数据来自 new-api 管理员接口 `/api/data/``/api/data/users`),
2525
以及**未来 7 天消费预测**(加权线性趋势 + 星期因子,附 80% 置信区间;历史满两周自动
2626
启用周末/工作日模式识别)
27+
- **利润分析**:读取自有站的渠道列表,按 base_url 与监控中的上游站点匹配(忽略协议 /
28+
末尾斜杠 / `/api` 后缀,同 URL 渠道合并)——利润 = 下游收入(消费 × 你的售价汇率)−
29+
各匹配上游的期内成本(按用量 × 充值汇率;配置了「每月固定成本」的按天摊销,
30+
月费 ÷ 30 × 窗口天数;用量接口不可用时退回余额下降推算)。
31+
未匹配的渠道单独列出,加入监控即可参与计算
2732
- **人民币折算**:每个站点可配置充值汇率(站点 $1 折合 ¥ 多少,如 1:2 充值);
2833
余额、消耗、图表等金额主显人民币(未配置按 1:1),站点原始余额作为次要信息展示;
2934
**余额告警阈值仍按站点余额判断**,不受汇率影响

lib/providers.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,42 @@ export async function queryOwnData(station, startMs, endMs, kind) {
509509
}));
510510
}
511511

512+
/**
513+
* 「我的站点」渠道列表(new-api 管理员接口,分页拉全)。
514+
* 返回 [{id, name, type, status, baseUrl}],status: 1=启用 2=手动禁用 3=自动禁用
515+
*/
516+
export async function queryOwnChannels(station) {
517+
const base = trimBase(station.baseUrl);
518+
const token = String(station.accessToken || "").trim();
519+
if (!base || !token) throw new Error("缺少站点地址或访问令牌");
520+
const headers = { Authorization: token };
521+
const userId = String(station.userId || "").trim();
522+
if (userId) headers["New-Api-User"] = userId;
523+
524+
const out = [];
525+
for (let p = 1; p <= 5; p++) {
526+
const r = await request(`${base}/api/channel/?p=${p}&page_size=100`, { headers, timeoutMs: 15000 });
527+
if (r.status >= 300) throw new Error(httpErrorMessage(r));
528+
if (r.body?.success === false) {
529+
const msg = String(r.body?.message || "");
530+
throw new Error(/privileg|unauthorized/i.test(msg)
531+
? "该令牌没有管理员权限,无法读取渠道列表" : msg || "获取渠道失败");
532+
}
533+
// 新版 data.items[],老版 data[]
534+
const items = Array.isArray(r.body?.data) ? r.body.data
535+
: Array.isArray(r.body?.data?.items) ? r.body.data.items : [];
536+
for (const c of items) {
537+
out.push({
538+
id: c.id, name: String(c.name || ""), type: Number(c.type) || 0,
539+
status: Number(c.status) || 0, baseUrl: String(c.base_url || ""),
540+
});
541+
}
542+
const total = Number(r.body?.data?.total);
543+
if (!items.length || !Number.isFinite(total) || out.length >= total) break;
544+
}
545+
return out;
546+
}
547+
512548
/**
513549
* 查询站点的用量明细(分模型 + 分时间),返回 {models, trend}。
514550
* 与余额查询不同,出错直接 throw,由调用方汇总每个站点的错误。

lib/store.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ export class Store {
181181
cnyPerUsd: numOrNull(input.cnyPerUsd),
182182
// 我自己的中转站:启用「我的站点」下游用量分析(需管理员令牌)
183183
isOwn: !!input.isOwn,
184+
// 每月固定成本(¥):包月/定期投入的上游,利润计算按天摊销并忽略其按用量成本
185+
fixedMonthlyCny: numOrNull(input.fixedMonthlyCny),
184186
demo: !!input.demo,
185187
createdAt: new Date().toISOString(),
186188
s2Tokens: null, // Sub2API 密码模式的令牌缓存 {accessToken, refreshToken, expiresAt}
@@ -205,6 +207,7 @@ export class Store {
205207
if ("lowBalanceUsd" in patch) s.lowBalanceUsd = numOrNull(patch.lowBalanceUsd);
206208
if ("cnyPerUsd" in patch) s.cnyPerUsd = numOrNull(patch.cnyPerUsd);
207209
if ("isOwn" in patch) s.isOwn = !!patch.isOwn;
210+
if ("fixedMonthlyCny" in patch) s.fixedMonthlyCny = numOrNull(patch.fixedMonthlyCny);
208211
// 凭证或站点实际变化才作废令牌缓存(前端编辑总会带上 type/email 原值,
209212
// 无脑作废会导致每次改名都触发一次完整重登录)
210213
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.4.0",
3+
"version": "1.5.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,7 @@ function renderOwnBody() {
900900
<div class="stat-card"><div class="label">请求数</div><div class="value">${totReqs.toLocaleString("en-US")}</div></div>
901901
<div class="stat-card"><div class="label">活跃用户</div><div class="value">${users.length}<small>个</small></div></div>
902902
</div>
903+
${profitSection(d.profit)}
903904
<div class="charts-grid">
904905
<div class="panel chart-card">
905906
<div class="chart-card-head"><div><h3>用量趋势</h3><div class="chart-sub">${hourly ? "按小时" : "按天"}汇总(tokens)</div></div></div>
@@ -954,6 +955,48 @@ function renderOwnBody() {
954955
fc ? fc.points.map((p) => ({ ...p, cost: p.cost * rate, lo: p.lo * rate, hi: p.hi * rate })) : null);
955956
}
956957

958+
// 利润区块:收入 − 匹配上游的成本;未匹配渠道单独列出
959+
function profitSection(p) {
960+
if (!p) return "";
961+
if (p.error) {
962+
return `<div class="usage-errors" style="margin-bottom:14px"><span>⚠ 利润分析不可用:${esc(p.error)}</span></div>`;
963+
}
964+
const MODE_LABEL = { usage: "按用量", fixed: "固定摊销", history: "余额推算 ≈" };
965+
const profitCls = p.profitCny >= 0 ? "good" : "danger";
966+
const costRows = p.costs.map((c) => `
967+
<div class="st-row profit-row">
968+
<div class="st-main" style="cursor:default">
969+
<div class="st-name">${esc(c.name)} <span class="demo-tag">${MODE_LABEL[c.mode] || c.mode}</span></div>
970+
<div class="st-meta">渠道:${esc(c.channels.join("、"))}</div>
971+
</div>
972+
<div class="st-balance"><div class="amt">${cny(c.cny)}</div><div class="sub">期内成本</div></div>
973+
</div>`).join("");
974+
const unmatchedRows = p.unmatched.length ? `
975+
<div class="section-head" style="margin-top:16px"><h2>未纳入成本的渠道</h2>
976+
<span class="muted">共 ${p.unmatched.length} 个上游(按 URL 合并)· 加入监控并配好汇率即可参与利润计算</span></div>
977+
<div class="panel">${p.unmatched.map((u) => `
978+
<div class="st-row profit-row">
979+
<div class="st-main" style="cursor:default">
980+
<div class="st-name">${esc(u.label)}</div>
981+
<div class="st-meta">${esc(u.names.join("、"))}</div>
982+
</div>
983+
<div class="st-balance"><div class="sub">${u.enabled}/${u.total} 个渠道启用</div></div>
984+
</div>`).join("")}</div>` : "";
985+
return `
986+
<div class="section-head"><h2>利润分析</h2>
987+
<span class="muted">收入按你的售价汇率 · 成本按各上游充值汇率(窗口 ${p.windowDays} 天)</span></div>
988+
<div class="stats" style="grid-template-columns:repeat(4,1fr)">
989+
<div class="stat-card"><div class="label">期内收入</div><div class="value">${cny(p.incomeCny)}</div></div>
990+
<div class="stat-card"><div class="label">期内成本</div><div class="value">${cny(p.totalCostCny)}</div></div>
991+
<div class="stat-card"><div class="label">利润</div><div class="value ${profitCls}">${cny(p.profitCny)}</div></div>
992+
<div class="stat-card"><div class="label">利润率</div><div class="value ${profitCls}">${p.marginPct != null ? p.marginPct + "%" : "—"}</div></div>
993+
</div>
994+
${p.costs.length ? `<div class="panel" style="margin-bottom:14px">${costRows}</div>`
995+
: '<div class="usage-errors" style="margin-bottom:14px"><span>没有渠道能匹配到监控中的站点(按 URL 比对),成本暂计 ¥0</span></div>'}
996+
${unmatchedRows}
997+
<div class="section-head" style="margin-top:16px"><h2>用量分析</h2></div>`;
998+
}
999+
9571000
// 分用户消费横向条形图(¥)
9581001
function drawOwnUsers(wrap, users) {
9591002
if (!users.length) {
@@ -1162,6 +1205,7 @@ function openModal(station) {
11621205
$("#f-password").value = "";
11631206
$("#f-lowBalance").value = station?.lowBalanceUsd ?? "";
11641207
$("#f-cnyRate").value = station?.cnyPerUsd ?? "";
1208+
$("#f-fixedCny").value = station?.fixedMonthlyCny ?? "";
11651209
$("#f-own").checked = !!station?.isOwn;
11661210
if (station) {
11671211
$("#f-accessToken").placeholder = station.hasAccessToken ? "已配置,留空保持不变" : "令牌 / JWT";
@@ -1205,6 +1249,7 @@ $("#modalSave").onclick = async () => {
12051249
email: $("#f-email").value.trim(),
12061250
lowBalanceUsd: $("#f-lowBalance").value.trim(),
12071251
cnyPerUsd: $("#f-cnyRate").value.trim(),
1252+
fixedMonthlyCny: $("#f-fixedCny").value.trim(),
12081253
isOwn: $("#f-type").value === "newapi" && $("#f-own").checked,
12091254
};
12101255
const at = $("#f-accessToken").value.trim();

public/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ <h2 id="modalTitle">添加中转站</h2>
153153
<input class="input" id="f-cnyRate" placeholder="如 2 表示 $1 = ¥2,留空按 1:1" />
154154
<div class="hint">面板金额将按此汇率折算成人民币展示;余额告警仍按站点余额判断。</div>
155155
</div>
156+
<div class="form-field">
157+
<label>每月固定成本(¥,可留空)</label>
158+
<input class="input" id="f-fixedCny" placeholder="包月/定期投入的上游填月费,如 199" />
159+
<div class="hint">利润计算按天摊销(月费 ÷ 30 × 天数),并忽略该站的按用量成本。</div>
160+
</div>
156161
<div class="form-field" id="f-own-wrap">
157162
<label class="chk-line"><input type="checkbox" id="f-own" /> 这是我自己的中转站</label>
158163
<div class="hint">启用「我的站点」下游分析(分用户/分模型用量与消费预测)。需要管理员(root)账号的系统访问令牌与用户 ID。</div>

public/styles.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ button { font-family: inherit; cursor: pointer; }
122122
.stat-card .value small { font-size: 12px; font-weight: 500; color: var(--text-tertiary); margin-left: 2px; }
123123
.stat-card .value.warn { color: var(--text-warning); }
124124
.stat-card .value.danger { color: var(--text-danger); }
125+
.stat-card .value.good { color: var(--text-success); }
126+
.profit-row { padding: 10px 16px; }
125127
.stat-card .stat-sub { font-size: 11px; color: var(--text-tertiary); margin-top: 3px; font-variant-numeric: tabular-nums; }
126128

127129
.section-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }

server.js

Lines changed: 93 additions & 1 deletion
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,
8+
queryStation, queryStationUsage, queryOwnData, queryOwnChannels,
99
dateStrInTz, parseDateLabel, STATION_TYPES,
1010
} from "./lib/providers.js";
1111
import { forecastDaily } from "./lib/forecast.js";
@@ -182,6 +182,20 @@ 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/channel/", (req, res) => {
186+
if (!needAuth(req, res)) return;
187+
const local = `http://${HOST}:${PORT}/mock`;
188+
res.json({
189+
success: true, message: "",
190+
data: { items: [
191+
{ id: 1, name: "上游A-高速", type: 1, status: 1, base_url: `${local}/newapi/np-pro` },
192+
{ id: 2, name: "上游A-备用", type: 1, status: 2, base_url: `${local}/newapi/np-pro` },
193+
{ id: 3, name: "拼车团队", type: 14, status: 1, base_url: `${local}/sub2api/s2-team` },
194+
{ id: 4, name: "官方直连-DeepSeek", type: 43, status: 1, base_url: "" },
195+
{ id: 5, name: "包月自建", type: 14, status: 1, base_url: "http://10.0.0.8:13800" },
196+
], total: 5 },
197+
});
198+
});
185199

186200
// 用户仪表盘统计(与真实 Sub2API 的 /usage/dashboard/stats 契约一致)
187201
mock.get("/sub2api/:acc/api/v1/usage/dashboard/stats", (req, res) => {
@@ -528,6 +542,7 @@ app.get("/api/own/analytics", async (req, res) => {
528542
trend: [...tmap.values()].sort((a, b) => a.t - b.t),
529543
daily: daily.slice(-14),
530544
forecast: forecastDaily(daily, 7),
545+
profit: await computeProfit(own, payloadIncome(winModel), { startMs, now, tz, range }),
531546
generatedAt: new Date().toISOString(),
532547
};
533548
ownCache.set(cacheKey, { at: Date.now(), payload });
@@ -537,6 +552,83 @@ app.get("/api/own/analytics", async (req, res) => {
537552
}
538553
});
539554

555+
function payloadIncome(winModelRows) {
556+
return winModelRows.reduce((a, r) => a + r.cost, 0);
557+
}
558+
559+
// 渠道列表拉取开销不小,缓存 10 分钟
560+
let ownChannelsCache = { at: 0, stationId: null, list: null };
561+
562+
/**
563+
* 利润 = 收入(下游消费 × 自有站售价汇率)− 成本(各匹配上游的期内成本)
564+
* 成本口径:配置了每月固定成本的上游按天摊销(月费 ÷ 30 × 窗口天数);
565+
* 否则按上游用量接口的实际扣费 × 充值汇率;用量接口不可用时退回余额下降推算。
566+
* 渠道按 base_url 与监控站点匹配(忽略协议/末尾斜杠/是否带 /api)。
567+
*/
568+
async function computeProfit(own, incomeUsd, { startMs, now, tz, range }) {
569+
const r2 = (v) => Math.round(v * 100) / 100;
570+
try {
571+
if (!ownChannelsCache.list || ownChannelsCache.stationId !== own.id || Date.now() - ownChannelsCache.at > 600000) {
572+
ownChannelsCache = { at: Date.now(), stationId: own.id, list: await queryOwnChannels(own) };
573+
}
574+
const channels = ownChannelsCache.list;
575+
const norm = (u) => String(u || "").toLowerCase().replace(/^https?:\/\//, "").replace(/\/+$/, "");
576+
const same = (a, b) => a && b && (a === b || a === b + "/api" || b === a + "/api");
577+
const upstreams = store.list().filter((s) => s.id !== own.id);
578+
579+
const matched = new Map(); // stationId -> {station, channels[]}
580+
const unmatched = new Map(); // label -> {label, names[], enabled, total}
581+
for (const ch of channels) {
582+
const cu = norm(ch.baseUrl);
583+
const st = cu ? upstreams.find((s) => same(norm(s.baseUrl), cu)) : null;
584+
if (st) {
585+
const e = matched.get(st.id) || { station: st, channels: [] };
586+
e.channels.push(ch.name);
587+
matched.set(st.id, e);
588+
} else {
589+
const label = cu || `官方 / 内置渠道(type ${ch.type})`;
590+
const e = unmatched.get(label) || { label, names: [], enabled: 0, total: 0 };
591+
e.names.push(ch.name);
592+
e.total++;
593+
if (ch.status === 1) e.enabled++;
594+
unmatched.set(label, e);
595+
}
596+
}
597+
598+
const windowDays = (now - startMs) / 86400000;
599+
const rateOf = (s) => (s.cnyPerUsd != null && s.cnyPerUsd > 0 ? s.cnyPerUsd : 1);
600+
const costs = await Promise.all([...matched.values()].map(async ({ station, channels: chNames }) => {
601+
const item = { stationId: station.id, name: station.name, channels: chNames };
602+
if (station.fixedMonthlyCny != null && station.fixedMonthlyCny > 0) {
603+
return { ...item, mode: "fixed", cny: r2((station.fixedMonthlyCny / 30) * windowDays) };
604+
}
605+
try {
606+
const u = await queryStationUsage(station, {
607+
startMs, endMs: now, granularity: "day", tz, wantToday: range === "today",
608+
});
609+
const usd = range === "today" && u.summary ? u.summary.cost : u.models.reduce((a, m) => a + m.cost, 0);
610+
return { ...item, mode: "usage", cny: r2(usd * rateOf(station)) };
611+
} catch {
612+
return { ...item, mode: "history", cny: r2(history.usedSince(station.id, startMs) * rateOf(station)) };
613+
}
614+
}));
615+
616+
const ownRate = own.cnyPerUsd != null && own.cnyPerUsd > 0 ? own.cnyPerUsd : 1;
617+
const incomeCny = r2(incomeUsd * ownRate);
618+
const totalCostCny = r2(costs.reduce((a, c) => a + c.cny, 0));
619+
return {
620+
incomeCny, totalCostCny,
621+
profitCny: r2(incomeCny - totalCostCny),
622+
marginPct: incomeCny > 0 ? Math.round(((incomeCny - totalCostCny) / incomeCny) * 1000) / 10 : null,
623+
costs: costs.sort((a, b) => b.cny - a.cny),
624+
unmatched: [...unmatched.values()].sort((a, b) => b.enabled - a.enabled || b.total - a.total),
625+
windowDays: Math.round(windowDays * 10) / 10,
626+
};
627+
} catch (err) {
628+
return { error: err?.message || String(err) };
629+
}
630+
}
631+
540632
// ---- 通知渠道 ----------------------------------------------------------------
541633
app.get("/api/notifications", (req, res) => {
542634
res.json({ channels: store.channels, rules: store.rules, channelTypes: CHANNEL_TYPES });

0 commit comments

Comments
 (0)