Skip to content

Commit fb9cbc6

Browse files
committed
fix: correct profit cost aggregation
1 parent 9a245de commit fb9cbc6

11 files changed

Lines changed: 252 additions & 42 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
- **余额预测**:余额历史(30 天)+ 实时速率分层估计(近 3 小时优先)预计耗尽时间;点击站点查看趋势图(历史折线 + 耗尽投影)
1818
- **通知告警**:余额偏低 / 耗尽 / 查询失败(**可配连续失败阈值与失败快速重试**)/ 恢复正常 / 预计即将耗尽(阈值可按天或小时),状态迁移触发、自动去重、可配重复提醒;可将单站标记为**不再续费**,低余额仅提醒一次;支持 10 种渠道:Telegram、钉钉(加签)、企业微信、飞书(签名)、Bark、ntfy、Server酱、Resend 邮件、SMTP 邮件(零依赖客户端)、自定义 Webhook,每渠道可单独测试
1919
- **我的站点(下游分析)**:自营 new-api 站点的分时段 / 分模型 / **分用户**用量与消费,**未来 7 天消费预测**(组合模型 + conformal 区间,历史满两周自动启用周末模式识别)
20-
- **利润分析**:下游收入(普通用户消费 × 售价汇率)− 上游期内成本(用量 × 充值汇率;固定成本按天摊销);**管理员 / root 转售 Key 可标记计入收入**未匹配渠道单独列出
20+
- **利润分析**:下游收入(普通用户消费 × 售价汇率)− 上游期内成本(用量 × 充值汇率;固定成本按天摊销);用量接口返回空数据但余额确有下降时自动回退历史推算;支持成本渠道匹配别名(容器域名 / 内网 IP)和 **Sub2API 成本汇总站模式**(New API 只接一个负载均衡入口时采用其 `actual_cost`,后层上游不重复计费);**管理员 / root 转售 Key 可标记计入收入**未匹配渠道与缺省汇率会明确标记利润不完整
2121
- **每日日报**:每天定时(默认北京时间,可用 `REPORT_TIME_ZONE` 覆盖)汇总昨日经营——消费环比、收入/成本/利润、Top 模型与用户、上游余额与耗尽预警、未来 7 天预测——推送到通知渠道(邮件全文,IM 截断);支持预览与立即发送
2222
- **人民币折算**:每站可配充值汇率(站点 $1 折合 ¥ 多少);金额主显人民币,站点原始余额次要展示;余额告警阈值仍按站点余额判断
2323
- **PWA**:可添加到手机主屏幕独立运行(品牌图标 + 离线壳缓存;静态资源网络优先,API 不缓存)

app/(dashboard)/my/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,15 @@ export default function MyStationPage() {
417417
) : null}
418418
{p && !p.error ? (
419419
<>
420+
{p.warnings?.length ? (
421+
<Alert
422+
style={{ marginTop: 14 }}
423+
type={p.complete ? "info" : "warning"}
424+
showIcon
425+
message={!p.complete ? "利润数据尚不完整" : p.estimated ? "成本中包含估算值" : "利润口径提示"}
426+
description={p.warnings.join(";")}
427+
/>
428+
) : null}
420429
<SectionHead
421430
title="利润分析"
422431
sub={`收入 = 普通用户消费 × 售价汇率(不含管理员/root,除非该 Key 已标为转售)· 成本按各上游口径(窗口 ${p.windowDays} 天)`}
@@ -560,7 +569,7 @@ export default function MyStationPage() {
560569
name={
561570
<>
562571
{c.name} <Tag>{MODE_LABEL[c.mode] || c.mode}</Tag>
563-
{c.note ? <Tag color={c.note === "已到期" ? "warning" : undefined}>{c.note}</Tag> : null}
572+
{c.note ? <Tag color={c.mode === "history" || c.note === "已到期" ? "warning" : undefined}>{c.note}</Tag> : null}
564573
</>
565574
}
566575
meta={`渠道:${c.channels.join("、")}`}

app/(dashboard)/stations/page.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ function StationRow(props: {
252252
<div style={{ fontWeight: 600 }}>
253253
{s.name}
254254
{s.isOwn ? <Tag color="blue" style={{ marginInlineStart: 6 }}>我的站</Tag> : null}
255+
{s.costGateway ? <Tag color="cyan" style={{ marginInlineStart: 6 }}>成本汇总站</Tag> : null}
255256
{s.noRenewal ? <Tag color="orange" style={{ marginInlineStart: 6 }}>不再续费</Tag> : null}
256257
{s.demo ? <Tag style={{ marginInlineStart: 6 }}>演示</Tag> : null}
257258
{statusPill(st)}
@@ -394,6 +395,8 @@ export default function StationsPage() {
394395
password: "",
395396
lowBalanceUsd: station?.lowBalanceUsd ?? "",
396397
cnyPerUsd: station?.cnyPerUsd ?? "",
398+
costAliasesText: Array.isArray(station?.costAliases) ? station.costAliases.join("\n") : "",
399+
costGateway: !!station?.costGateway,
397400
isOwn: !!station?.isOwn,
398401
noRenewal: !!station?.noRenewal,
399402
});
@@ -417,6 +420,11 @@ export default function StationsPage() {
417420
email: String(v.email || "").trim(),
418421
lowBalanceUsd: String(v.lowBalanceUsd ?? "").trim(),
419422
cnyPerUsd: String(v.cnyPerUsd ?? "").trim(),
423+
costAliases: String(v.costAliasesText || "")
424+
.split(/[\n,]/)
425+
.map((x) => x.trim())
426+
.filter(Boolean),
427+
costGateway: String(v.type || "").startsWith("sub2api") && !!v.costGateway,
420428
// 金额/天数保持字符串提交(同 v1 collectPurchases),全空行剔除
421429
fixedPurchases: purchases
422430
.map((p) => ({
@@ -579,6 +587,26 @@ export default function StationsPage() {
579587
<Input placeholder="如 2 表示 $1 = ¥2,留空按 1:1" />
580588
</Form.Item>
581589
)}
590+
{String(formType || "").startsWith("sub2api") && (
591+
<Form.Item
592+
name="costGateway"
593+
valuePropName="checked"
594+
style={{ marginBottom: 12 }}
595+
extra="适用于 New API 只连接这一个 Sub2API 负载均衡入口的架构。利润只采用本站 actual_cost 作为全部用量成本,其后的监控上游不会重复计入。全局只能设置一个。"
596+
>
597+
<Checkbox>这是成本汇总站</Checkbox>
598+
</Form.Item>
599+
)}
600+
{!isFixed && (
601+
<Form.Item
602+
label="成本渠道匹配别名"
603+
name="costAliasesText"
604+
style={{ marginBottom: 12 }}
605+
extra="当自有站渠道使用容器域名、内网 IP 或代理地址时,每行填写一个渠道地址;利润计算会将它们归属到此上游。"
606+
>
607+
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder={"例如:sub2api-internal\n10.0.0.8:8080"} />
608+
</Form.Item>
609+
)}
582610
{!isFixed && (
583611
<Form.Item
584612
name="noRenewal"

app/api/stations/[id]/route.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ export const PUT = withAuth(async (request, rt, params) => {
1010
return json({ error: "无效的中转站类型" }, 400);
1111
const s = await rt.store.update(params.id, b);
1212
if (!s) return json({ error: "未找到该中转站" }, 404);
13+
rt._ownCache?.clear();
14+
delete rt._ownChannelsCache;
15+
delete rt._ownUsersCache;
1316
refreshStation(rt, s).catch(() => {});
1417
return json({ station: redact(rt, s) });
1518
});
1619

1720
export const DELETE = withAuth(async (request, rt, params) => {
1821
const ok = await rt.store.remove(params.id);
22+
rt._ownCache?.clear();
23+
delete rt._ownChannelsCache;
24+
delete rt._ownUsersCache;
1925
rt.history.remove(params.id);
2026
return json({ ok });
2127
});

app/api/stations/route.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export const POST = withAuth(async (request, rt) => {
1414
return json({ error: "无效的中转站类型" }, 400);
1515
if (!b.baseUrl && b.type !== "fixed") return json({ error: "请填写站点地址" }, 400);
1616
const s = await rt.store.add(b);
17+
rt._ownCache?.clear();
18+
delete rt._ownChannelsCache;
19+
delete rt._ownUsersCache;
1720
refreshStation(rt, s).catch(() => {});
1821
return json({ station: redact(rt, s) });
1922
});

db/store.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ function numOrNull(v) {
2222
return Number.isFinite(n) ? n : null;
2323
}
2424

25+
// 成本渠道匹配别名:用于渠道配置采用容器域名/IP、与监控地址不一致的场景。
26+
function sanitizeCostAliases(input) {
27+
const rows = Array.isArray(input) ? input : String(input || "").split(/[\n,]/);
28+
return [...new Set(rows.map((v) => String(v || "").trim()).filter(Boolean))];
29+
}
30+
2531
// 固定成本付费记录:[{amount(¥), days, startDate|null}],无效行直接丢弃
2632
function sanitizePurchases(input) {
2733
if (!Array.isArray(input)) return null;
@@ -92,6 +98,8 @@ export class Store {
9298
for (const s of this.data.stations) {
9399
// v2.1:旧站点没有续费计划字段,默认保持原有的正常重复提醒策略。
94100
s.noRenewal = !!s.noRenewal;
101+
s.costAliases = sanitizeCostAliases(s.costAliases);
102+
s.costGateway = ["sub2api", "sub2api-password"].includes(s.type) && !!s.costGateway;
95103
if (!Array.isArray(s.fixedPurchases)) {
96104
s.fixedPurchases = [];
97105
const amount = s.fixedCostCny ?? s.fixedMonthlyCny;
@@ -280,6 +288,10 @@ export class Store {
280288
lowBalanceUsd: numOrNull(input.lowBalanceUsd),
281289
// 充值折算汇率:站点 $1 折合人民币(¥);null 按 1:1 展示
282290
cnyPerUsd: numOrNull(input.cnyPerUsd),
291+
// 自有站渠道可能使用容器域名、内网 IP 等地址;别名参与利润成本归属匹配。
292+
costAliases: sanitizeCostAliases(input.costAliases),
293+
// 成本汇总站:其 actual_cost 已覆盖后面的负载均衡上游,利润中只计这一层。
294+
costGateway: ["sub2api", "sub2api-password"].includes(input.type) && !!input.costGateway,
283295
// 我自己的中转站:启用「我的站点」下游用量分析(需管理员令牌)
284296
isOwn: !!input.isOwn,
285297
// 不再续费:余额低于阈值时仅提醒一次,不再发送耗尽/ETA/持续余额提醒
@@ -294,6 +306,9 @@ export class Store {
294306
alertState: null, // 告警去重状态(含不再续费站点的一次性低余额提醒时间)
295307
balance: null, // 最近一次查询结果
296308
};
309+
if (station.costGateway) {
310+
for (const s of this.data.stations) s.costGateway = false;
311+
}
297312
this.data.stations.push(station);
298313
await this.save();
299314
return station;
@@ -311,6 +326,13 @@ export class Store {
311326
if ("password" in patch) s.password = String(patch.password ?? "");
312327
if ("lowBalanceUsd" in patch) s.lowBalanceUsd = numOrNull(patch.lowBalanceUsd);
313328
if ("cnyPerUsd" in patch) s.cnyPerUsd = numOrNull(patch.cnyPerUsd);
329+
if ("costAliases" in patch) s.costAliases = sanitizeCostAliases(patch.costAliases);
330+
if ("costGateway" in patch || !["sub2api", "sub2api-password"].includes(s.type)) {
331+
s.costGateway = ["sub2api", "sub2api-password"].includes(s.type) && !!patch.costGateway;
332+
if (s.costGateway) {
333+
for (const other of this.data.stations) if (other.id !== s.id) other.costGateway = false;
334+
}
335+
}
314336
if ("isOwn" in patch) s.isOwn = !!patch.isOwn;
315337
if ("noRenewal" in patch || s.type === "fixed") {
316338
const noRenewal = s.type !== "fixed" && !!patch.noRenewal;

db/store.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,29 @@ test("重新标记不再续费会重置单次提醒资格", async () => {
4949
await store.update("station-1", { type: "fixed", noRenewal: true });
5050
assert.equal(store.get("station-1").noRenewal, false);
5151
});
52+
53+
test("成本渠道匹配别名会清理空值并去重", async () => {
54+
const store = new Store(fakePool());
55+
const added = await store.add({
56+
name: "上游",
57+
type: "newapi",
58+
baseUrl: "https://public.example.com",
59+
costAliases: [" internal-host ", "", "internal-host", "10.0.0.8:8080"],
60+
});
61+
assert.deepEqual(added.costAliases, ["internal-host", "10.0.0.8:8080"]);
62+
63+
await store.update(added.id, { costAliases: "alias-a, alias-b\nalias-a" });
64+
assert.deepEqual(store.get(added.id).costAliases, ["alias-a", "alias-b"]);
65+
});
66+
67+
test("全局只保留一个 Sub2API 成本汇总站", async () => {
68+
const store = new Store(fakePool());
69+
const first = await store.add({ name: "网关 A", type: "sub2api", baseUrl: "https://a.example.com", costGateway: true });
70+
const second = await store.add({ name: "网关 B", type: "sub2api-password", baseUrl: "https://b.example.com", costGateway: true });
71+
assert.equal(store.get(first.id).costGateway, false);
72+
assert.equal(store.get(second.id).costGateway, true);
73+
74+
await store.update(first.id, { costGateway: true });
75+
assert.equal(store.get(first.id).costGateway, true);
76+
assert.equal(store.get(second.id).costGateway, false);
77+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"dev": "next dev",
1010
"build": "next build",
1111
"start": "next start",
12-
"test": "node --test lib/alerts.test.js db/store.test.js",
12+
"test": "node --test lib/alerts.test.js db/store.test.js server/own-helpers.test.js",
1313
"db:migrate": "node db/migrate.js"
1414
},
1515
"dependencies": {

0 commit comments

Comments
 (0)