Skip to content

Commit 07319f4

Browse files
committed
管理员/root 转售 Key 计入收入 (v1.14.0)
管理员/root 账号里实际转售给下游的 API Key,其消费改从「管理员消耗 (成本)」重归为「下游收入 × 售价汇率」,流入既有利润计算。 - 新增 lib/providers.js: queryLogStat(/api/log/stat 按 用户名+Key名 取 窗内消费聚合,一次请求拿到,不必翻全量日志)、queryAdminTokens(列某 账号名下 Key) - Key 名跨用户重名,故按 (username, tokenName) 二元组唯一定位 - 站点新增 resoldAdminKeys 配置;computeResold 做口径重归(收入 +,管理 员消耗 −,跨接口口径微差钳到 0);「我的站点」页与每日日报同步生效 - 新增 GET/PUT /api/own/admin-keys:列管理员账号 Key 并保存转售标记; /api/token/ 无法越权枚举其它账号(如 root),该账号退回手动填 Key 名 - 前端「管理员转售 Key」管理器:可枚举账号勾选、不可枚举账号手动添加, 保存后重算;利润卡片拆分显示「含转售 Key / 管理员自用」
1 parent 7e0e8e2 commit 07319f4

8 files changed

Lines changed: 311 additions & 21 deletions

File tree

.claude/launch.json

Lines changed: 0 additions & 11 deletions
This file was deleted.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ node_modules/
33

44
# 运行时数据(含中转站凭证、面板密码哈希、会话密钥、余额历史)——绝不入库
55
data/
6+
7+
# 本地工具配置
8+
.claude/

lib/providers.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,66 @@ export async function queryOwnUsers(station) {
582582
return out;
583583
}
584584

585+
/**
586+
* 「我的站点」某个账号的 API Key 列表(new-api 管理员接口)。
587+
* new-api 的 /api/token/ 按「当前操作用户」返回其名下 Key,通过 New-Api-User 头切换目标账号。
588+
* 返回 [{id, name, usedUsd, remainUsd, status}],status: 1=启用 其它=禁用/过期
589+
*/
590+
export async function queryAdminTokens(station, targetUserId) {
591+
const base = trimBase(station.baseUrl);
592+
const token = String(station.accessToken || "").trim();
593+
if (!base || !token) throw new Error("缺少站点地址或访问令牌");
594+
const headers = { Authorization: token, "New-Api-User": String(targetUserId) };
595+
596+
const out = [];
597+
for (let p = 1; p <= 5; p++) {
598+
const r = await request(`${base}/api/token/?p=${p}&page_size=100`, { headers, timeoutMs: 15000 });
599+
if (r.status >= 300) throw new Error(httpErrorMessage(r));
600+
if (r.body?.success === false) throw new Error(r.body?.message || "获取 Key 列表失败");
601+
const items = Array.isArray(r.body?.data) ? r.body.data
602+
: Array.isArray(r.body?.data?.items) ? r.body.data.items : [];
603+
for (const t of items) {
604+
out.push({
605+
id: t.id,
606+
name: String(t.name || ""),
607+
status: Number(t.status) || 0,
608+
usedUsd: num(t.used_quota) / QUOTA_PER_UNIT,
609+
remainUsd: t.unlimited_quota ? null : num(t.remain_quota) / QUOTA_PER_UNIT,
610+
});
611+
}
612+
const total = Number(r.body?.data?.total);
613+
if (!items.length || !Number.isFinite(total) || out.length >= total) break;
614+
}
615+
return out;
616+
}
617+
618+
/**
619+
* 用日志统计接口聚合某个 (用户名 + Key 名) 在时间窗内的消费额度(new-api 管理员接口)。
620+
* GET /api/log/stat?type=2 返回 {quota, rpm, tpm}——quota 为窗内消费额度总和。
621+
* ⚠ Key 名会跨用户重名,必须同时带 username 与 token_name 才能唯一定位。
622+
* 返回消费金额(美元);无数据返回 0。
623+
*/
624+
export async function queryLogStat(station, { username, tokenName, startMs, endMs }) {
625+
const base = trimBase(station.baseUrl);
626+
const token = String(station.accessToken || "").trim();
627+
if (!base || !token) throw new Error("缺少站点地址或访问令牌");
628+
const headers = { Authorization: token };
629+
const userId = String(station.userId || "").trim();
630+
if (userId) headers["New-Api-User"] = userId;
631+
632+
const qs = new URLSearchParams({
633+
type: "2",
634+
username: String(username || ""),
635+
token_name: String(tokenName || ""),
636+
start_timestamp: String(Math.floor(startMs / 1000)),
637+
end_timestamp: String(Math.ceil(endMs / 1000)),
638+
});
639+
const r = await request(`${base}/api/log/stat?${qs}`, { headers, timeoutMs: 20000 });
640+
if (r.status >= 300) throw new Error(httpErrorMessage(r));
641+
if (r.body?.success === false) throw new Error(r.body?.message || "日志统计查询失败");
642+
return num(r.body?.data?.quota) / QUOTA_PER_UNIT;
643+
}
644+
585645
/**
586646
* 查询站点的用量明细(分模型 + 分时间),返回 {models, trend}。
587647
* 与余额查询不同,出错直接 throw,由调用方汇总每个站点的错误。

lib/store.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ function sanitizePurchases(input) {
3838
return out;
3939
}
4040

41+
// 转售的管理员/root API Key:(用户名, Key 名) 二元组唯一定位(Key 名跨用户重名)。
42+
// 这些 Key 的消费从「管理员消耗(成本)」重归为「下游收入」。
43+
function sanitizeResoldKeys(input) {
44+
if (!Array.isArray(input)) return null;
45+
const out = [], seen = new Set();
46+
for (const k of input) {
47+
const username = String(k?.username || "").trim();
48+
const tokenName = String(k?.tokenName ?? k?.token_name ?? "").trim();
49+
if (!username || !tokenName) continue;
50+
const dedup = `${username} ${tokenName}`;
51+
if (seen.has(dedup)) continue;
52+
seen.add(dedup);
53+
out.push({ username, tokenName });
54+
}
55+
return out;
56+
}
57+
4158
export class Store {
4259
constructor(file) {
4360
this.file = file;
@@ -234,6 +251,8 @@ export class Store {
234251
isOwn: !!input.isOwn,
235252
// 固定成本付费记录(可多笔叠加):每笔按 金额÷天数 在生效区间内摊销
236253
fixedPurchases: sanitizePurchases(input.fixedPurchases) || [],
254+
// 转售给下游的管理员/root API Key(其消费计入收入而非成本)
255+
resoldAdminKeys: sanitizeResoldKeys(input.resoldAdminKeys) || [],
237256
demo: !!input.demo,
238257
createdAt: new Date().toISOString(),
239258
s2Tokens: null, // Sub2API 密码模式的令牌缓存 {accessToken, refreshToken, expiresAt}
@@ -259,6 +278,7 @@ export class Store {
259278
if ("cnyPerUsd" in patch) s.cnyPerUsd = numOrNull(patch.cnyPerUsd);
260279
if ("isOwn" in patch) s.isOwn = !!patch.isOwn;
261280
if ("fixedPurchases" in patch) s.fixedPurchases = sanitizePurchases(patch.fixedPurchases) || [];
281+
if ("resoldAdminKeys" in patch) s.resoldAdminKeys = sanitizeResoldKeys(patch.resoldAdminKeys) || [];
262282
// 凭证或站点实际变化才作废令牌缓存(前端编辑总会带上 type/email 原值,
263283
// 无脑作废会导致每次改名都触发一次完整重登录)
264284
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.13.0",
3+
"version": "1.14.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ const api = {
5656
overview: (hours) => call(`/api/history/overview?hours=${hours}`),
5757
usage: (range) => call(`/api/usage?range=${range}&tz=${encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)}`),
5858
ownAnalytics: (range) => call(`/api/own/analytics?range=${range}&tz=${encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)}`),
59+
ownAdminKeys: () => call("/api/own/admin-keys"),
60+
saveOwnAdminKeys: (keys) => call("/api/own/admin-keys", { method: "PUT", body: { keys } }),
5961
saveSettings: (b) => call("/api/settings", { method: "PUT", body: b }),
6062
notifications: () => call("/api/notifications"),
6163
addChannel: (b) => call("/api/notifications/channels", { body: b }),
@@ -1189,23 +1191,93 @@ function profitSection(p) {
11891191
</div>
11901192
<div class="st-balance"><div class="sub">${u.enabled}/${u.total} 个渠道启用</div></div>
11911193
</div>`).join("")}</div>` : "";
1194+
const resoldCny = p.resoldCny || 0;
1195+
const incomeSub = [
1196+
resoldCny > 0 ? `含转售管理员 Key ${cny(resoldCny)}` : "",
1197+
p.adminUsageCny > 0 ? `管理员自用 ${cny(p.adminUsageCny)}(计成本不计收入)` : "",
1198+
].filter(Boolean).join(" · ");
11921199
return `
11931200
<div class="section-head" data-toc="利润分析"><h2>利润分析</h2>
1194-
<span class="muted">收入 = 普通用户消费 × 售价汇率(不含管理员/root)· 成本按各上游口径(窗口 ${p.windowDays} 天)</span></div>
1201+
<span class="muted">收入 = 普通用户消费 × 售价汇率(不含管理员/root,除非该 Key 已标为转售)· 成本按各上游口径(窗口 ${p.windowDays} 天)</span></div>
11951202
<div class="stats" style="grid-template-columns:repeat(4,1fr)">
11961203
<div class="stat-card"><div class="label">期内收入</div><div class="value">${cny(p.incomeCny)}</div>${
1197-
p.adminUsageCny > 0 ? `<div class="stat-sub">管理员另消耗 ${cny(p.adminUsageCny)}(计成本不计收入)</div>` : ""
1204+
incomeSub ? `<div class="stat-sub">${incomeSub}</div>` : ""
11981205
}</div>
11991206
<div class="stat-card"><div class="label">期内成本</div><div class="value">${cny(p.totalCostCny)}</div></div>
12001207
<div class="stat-card"><div class="label">利润</div><div class="value ${profitCls}">${cny(p.profitCny)}</div></div>
12011208
<div class="stat-card"><div class="label">利润率</div><div class="value ${profitCls}">${p.marginPct != null ? p.marginPct + "%" : "—"}</div></div>
12021209
</div>
1210+
${resoldSection(p)}
12031211
${p.costs.length ? `<div class="panel" style="margin-bottom:14px">${costRows}</div>`
12041212
: '<div class="usage-errors" style="margin-bottom:14px"><span>没有渠道能匹配到监控中的站点(按 URL 比对),成本暂计 ¥0</span></div>'}
12051213
${unmatchedRows}
12061214
<div class="section-head" style="margin-top:16px" data-toc="用量图表"><h2>用量分析</h2></div>`;
12071215
}
12081216

1217+
// 转售管理员 Key 区块:已标记 Key 的收入明细 + 打开管理器按钮
1218+
function resoldSection(p) {
1219+
const rows = (p.resoldKeys || []).map((k) => `
1220+
<div class="st-row profit-row">
1221+
<div class="st-main" style="cursor:default">
1222+
<div class="st-name">${esc(k.username)} / ${esc(k.tokenName)}${
1223+
k.error ? ` <span class="demo-tag tag-warn">查询失败</span>` : ""
1224+
}</div>
1225+
<div class="st-meta">${k.error ? esc(k.error) : "转售给下游 · 计入收入"}</div>
1226+
</div>
1227+
<div class="st-balance"><div class="amt">${cny(k.cny || 0)}</div><div class="sub">期内收入</div></div>
1228+
</div>`).join("");
1229+
return `
1230+
<div class="section-head" style="margin-top:16px" data-toc="转售 Key">
1231+
<h2>管理员转售 Key</h2>
1232+
<button class="btn btn-ghost" id="manageResold">管理转售 Key</button></div>
1233+
${rows ? `<div class="panel" style="margin-bottom:14px">${rows}</div>`
1234+
: `<div class="usage-errors" style="margin-bottom:14px"><span>还没有标记转售 Key。若某个管理员/root 账号的 API Key 实际给了下游,点「管理转售 Key」勾选它,其消费即计入收入。</span></div>`}
1235+
<div id="resoldManager"></div>`;
1236+
}
1237+
1238+
// 渲染转售 Key 管理器:按管理员账号分组,逐个 Key 复选
1239+
function renderResoldManager(box, accounts) {
1240+
const ROLE = { 10: "管理员", 100: "root" };
1241+
if (!accounts || !accounts.length) {
1242+
box.innerHTML = `<div class="usage-errors" style="margin-bottom:14px"><span>没有找到管理员/root 账号(role ≥ 10)。</span></div>`;
1243+
return;
1244+
}
1245+
const rate = rateOf(state.ownData.station);
1246+
const keyRow = (user, name, checked, usedUsd) => `
1247+
<label class="resold-key">
1248+
<input type="checkbox" data-user="${esc(user)}" data-token="${esc(name)}" ${checked ? "checked" : ""}>
1249+
<span class="resold-key-name">${esc(name) || "(未命名)"}</span>
1250+
${usedUsd != null ? `<span class="resold-key-used">累计用 ${cny(usedUsd * rate)}</span>` : ""}
1251+
</label>`;
1252+
const groups = accounts.map((a) => {
1253+
const head = `<div class="st-name" style="margin:2px 0 8px">${esc(a.username)} <span class="demo-tag">${ROLE[a.role] || "role " + a.role}</span></div>`;
1254+
// 可枚举:直接列出所有 Key 复选
1255+
if (a.enumerable) {
1256+
if (!a.tokens.length) return `<div class="resold-group">${head}<div class="st-meta">该账号没有 API Key</div></div>`;
1257+
return `<div class="resold-group">${head}${a.tokens.map((t) => keyRow(a.username, t.name, t.flagged, t.usedUsd)).join("")}</div>`;
1258+
}
1259+
// 不可枚举(如 root,接口不允许越权列 Key):回显已标记的 + 手动填 Key 名
1260+
const existing = a.tokens.map((t) => keyRow(a.username, t.name, true, null)).join("");
1261+
return `<div class="resold-group" data-acct="${esc(a.username)}">
1262+
${head}
1263+
<div class="st-meta" style="margin-bottom:6px">此账号无法自动列出 Key(${esc(a.error || "接口限制")})。若它有转售 Key,请手动填 Key 名:</div>
1264+
<div class="resold-keys">${existing}</div>
1265+
<div class="resold-add">
1266+
<input class="input small" placeholder="Key 名(token_name)" data-addfor="${esc(a.username)}">
1267+
<button class="btn btn-ghost" data-resold-add="${esc(a.username)}">添加</button>
1268+
</div>
1269+
</div>`;
1270+
}).join("");
1271+
box.innerHTML = `
1272+
<div class="panel" style="margin-bottom:14px;padding:16px">
1273+
<div class="st-meta" style="margin-bottom:10px">勾选实际转售给下游的 Key,其期内消费将从「管理员自用(成本)」改计入「收入 × 售价汇率」。Key 名可能跨账号重名,故按「账号 + Key 名」定位。</div>
1274+
${groups}
1275+
<div style="margin-top:12px;display:flex;gap:8px">
1276+
<button class="btn btn-primary" id="resoldSave">保存并重算</button>
1277+
</div>
1278+
</div>`;
1279+
}
1280+
12091281
// 分用户消费横向条形图(¥)
12101282
function drawOwnUsers(wrap, users) {
12111283
if (!users.length) {
@@ -1790,6 +1862,52 @@ $(".main").addEventListener("click", async (e) => {
17901862
return;
17911863
}
17921864

1865+
// 管理员转售 Key:打开管理器 / 收起 / 保存
1866+
if (e.target.closest("#manageResold")) {
1867+
const box = $("#resoldManager");
1868+
if (box.dataset.open === "1") { box.dataset.open = "0"; box.innerHTML = ""; return; }
1869+
box.dataset.open = "1";
1870+
box.innerHTML = `<div class="panel" style="margin-bottom:14px"><div class="empty" style="padding:20px">正在拉取管理员账号的 Key…</div></div>`;
1871+
try {
1872+
const { accounts } = await api.ownAdminKeys();
1873+
renderResoldManager(box, accounts);
1874+
} catch (err) {
1875+
box.innerHTML = `<div class="usage-errors" style="margin-bottom:14px"><span>⚠ 拉取失败:${esc(err.message)}</span></div>`;
1876+
}
1877+
return;
1878+
}
1879+
const addBtn = e.target.closest("[data-resold-add]");
1880+
if (addBtn) {
1881+
const user = addBtn.dataset.resoldAdd;
1882+
const input = document.querySelector(`input[data-addfor="${CSS.escape(user)}"]`);
1883+
const name = (input?.value || "").trim();
1884+
if (!name) return;
1885+
const list = addBtn.closest(".resold-group").querySelector(".resold-keys");
1886+
// 已存在则不重复添加
1887+
if (![...list.querySelectorAll("input")].some((c) => c.dataset.token === name)) {
1888+
const label = document.createElement("label");
1889+
label.className = "resold-key";
1890+
label.innerHTML = `<input type="checkbox" data-user="${esc(user)}" data-token="${esc(name)}" checked>
1891+
<span class="resold-key-name">${esc(name)}</span>`;
1892+
list.appendChild(label);
1893+
}
1894+
input.value = "";
1895+
return;
1896+
}
1897+
if (e.target.closest("#resoldSave")) {
1898+
const keys = [...document.querySelectorAll("#resoldManager input[type=checkbox]:checked")]
1899+
.map((c) => ({ username: c.dataset.user, tokenName: c.dataset.token }));
1900+
const btn = e.target.closest("#resoldSave");
1901+
btn.disabled = true;
1902+
try {
1903+
await api.saveOwnAdminKeys(keys);
1904+
toast(`已保存 ${keys.length} 个转售 Key,正在重算利润…`);
1905+
$("#resoldManager").dataset.open = "0";
1906+
await loadOwn(true);
1907+
} catch (err) { toast(err.message, "err"); btn.disabled = false; }
1908+
return;
1909+
}
1910+
17931911
// 规则开关 / 保存
17941912
const ruleToggle = e.target.closest("[data-rule]");
17951913
if (ruleToggle) {

public/styles.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,3 +452,14 @@ button { font-family: inherit; cursor: pointer; }
452452
.pw-grid { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; }
453453
.pw-grid .form-field label { font-size: 11.5px; }
454454
@media (max-width: 720px) { .pw-grid { grid-template-columns: 1fr; } }
455+
456+
/* ---- 管理员转售 Key 管理器 ---- */
457+
.resold-group { padding: 8px 0; border-top: 1px solid var(--border); }
458+
.resold-group:first-of-type { border-top: none; padding-top: 0; }
459+
.resold-key { display: flex; align-items: center; gap: 10px; padding: 6px 4px; cursor: pointer; border-radius: 7px; }
460+
.resold-key:hover { background: var(--bg-hover, rgba(127,127,127,.07)); }
461+
.resold-key input { width: 15px; height: 15px; accent-color: var(--primary); flex: none; }
462+
.resold-key-name { font-size: 13px; color: var(--text-primary); font-family: var(--mono, ui-monospace, monospace); }
463+
.resold-key-used { margin-left: auto; font-size: 11.5px; color: var(--text-tertiary); }
464+
.resold-add { display: flex; gap: 8px; margin-top: 8px; }
465+
.resold-add .input { flex: 1; max-width: 260px; }

0 commit comments

Comments
 (0)