Skip to content

Commit 8f82f1c

Browse files
committed
fix: include all monitored upstream costs
1 parent fb9cbc6 commit 8f82f1c

10 files changed

Lines changed: 81 additions & 84 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-
- **利润分析**:下游收入(普通用户消费 × 售价汇率)− 上游期内成本(用量 × 充值汇率固定成本按天摊销);用量接口返回空数据但余额确有下降时自动回退历史推算;支持成本渠道匹配别名(容器域名 / 内网 IP)**Sub2API 成本汇总站模式**(New API 只接一个负载均衡入口时采用其 `actual_cost`,后层上游不重复计费)**管理员 / root 转售 Key 可标记计入收入**未匹配渠道与缺省汇率会明确标记利润不完整
20+
- **利润分析**:下游收入(普通用户消费 × 售价汇率)− 全部监控上游的期内成本(不要求出现在 New API 渠道列表;用量 × 充值汇率固定成本按天摊销);用量接口返回空数据但余额确有下降时自动回退历史推算;纯观察或重复汇总节点可关闭“计入利润成本”;支持渠道匹配别名(容器域名 / 内网 IP);**管理员 / root 转售 Key 可标记计入收入**缺省汇率会明确标记利润不完整
2121
- **每日日报**:每天定时(默认北京时间,可用 `REPORT_TIME_ZONE` 覆盖)汇总昨日经营——消费环比、收入/成本/利润、Top 模型与用户、上游余额与耗尽预警、未来 7 天预测——推送到通知渠道(邮件全文,IM 截断);支持预览与立即发送
2222
- **人民币折算**:每站可配充值汇率(站点 $1 折合 ¥ 多少);金额主显人民币,站点原始余额次要展示;余额告警阈值仍按站点余额判断
2323
- **PWA**:可添加到手机主屏幕独立运行(品牌图标 + 离线壳缓存;静态资源网络优先,API 不缓存)

app/(dashboard)/analytics/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export default function AnalyticsPage() {
8383
// ---- 派生数据 ---------------------------------------------------------------
8484
const derived = useMemo(() => {
8585
if (!data) return null;
86-
const upstream = data.stations.filter((s: any) => !s.isOwn);
86+
const upstream = data.stations.filter((s: any) => !s.isOwn && s.includeInProfit !== false);
8787
const upstreamIds = new Set(upstream.map((s: any) => s.id));
8888

8989
// 窗口内完整日期序列(缺日补 0,图表 x 轴连续)

app/(dashboard)/my/page.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ export default function MyStationPage() {
558558
</ProCard>
559559
) : null}
560560

561-
{/* 成本明细(按上游口径),区块标题与「未纳入成本的渠道」等保持一致 */}
561+
{/* 成本明细:所有启用计入成本的监控上游,不依赖渠道 URL 是否匹配 */}
562562
{p.costs.length ? (
563563
<>
564564
<SectionHead title="成本明细" sub={`共 ${p.costs.length} 个纳入成本的上游 · 按各上游口径计入期内成本`} />
@@ -584,16 +584,16 @@ export default function MyStationPage() {
584584
style={{ marginTop: 14 }}
585585
type="warning"
586586
showIcon
587-
message="没有渠道能匹配到监控中的站点(按 URL 比对),成本暂计 ¥0"
587+
message="没有启用计入利润成本的上游,成本暂计 ¥0"
588588
/>
589589
)}
590590

591-
{/* 未纳入成本的渠道 */}
591+
{/* 未直接关联监控站的渠道,仅用于检查是否还有未监控成本 */}
592592
{p.unmatched.length ? (
593593
<>
594594
<SectionHead
595-
title="未纳入成本的渠道"
596-
sub={`共 ${p.unmatched.length} 个上游(按 URL 合并)· 加入监控并配好汇率即可参与利润计算`}
595+
title="未直接关联监控站的渠道"
596+
sub={`共 ${p.unmatched.length} 个渠道地址(按 URL 合并)· 监控列表中的上游成本已独立计入,此处仅用于检查是否还有未添加的外部上游`}
597597
/>
598598
<ProCard>
599599
{p.unmatched.map((u: any) => (

app/(dashboard)/stations/page.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +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}
255+
{s.includeInProfit === false ? <Tag style={{ marginInlineStart: 6 }}>不计利润成本</Tag> : null}
256256
{s.noRenewal ? <Tag color="orange" style={{ marginInlineStart: 6 }}>不再续费</Tag> : null}
257257
{s.demo ? <Tag style={{ marginInlineStart: 6 }}>演示</Tag> : null}
258258
{statusPill(st)}
@@ -396,7 +396,7 @@ export default function StationsPage() {
396396
lowBalanceUsd: station?.lowBalanceUsd ?? "",
397397
cnyPerUsd: station?.cnyPerUsd ?? "",
398398
costAliasesText: Array.isArray(station?.costAliases) ? station.costAliases.join("\n") : "",
399-
costGateway: !!station?.costGateway,
399+
includeInProfit: station?.includeInProfit !== false,
400400
isOwn: !!station?.isOwn,
401401
noRenewal: !!station?.noRenewal,
402402
});
@@ -424,7 +424,7 @@ export default function StationsPage() {
424424
.split(/[\n,]/)
425425
.map((x) => x.trim())
426426
.filter(Boolean),
427-
costGateway: String(v.type || "").startsWith("sub2api") && !!v.costGateway,
427+
includeInProfit: !!v.includeInProfit,
428428
// 金额/天数保持字符串提交(同 v1 collectPurchases),全空行剔除
429429
fixedPurchases: purchases
430430
.map((p) => ({
@@ -587,16 +587,16 @@ export default function StationsPage() {
587587
<Input placeholder="如 2 表示 $1 = ¥2,留空按 1:1" />
588588
</Form.Item>
589589
)}
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-
)}
590+
<Form.Item
591+
name="includeInProfit"
592+
valuePropName="checked"
593+
style={{ marginBottom: 12 }}
594+
extra={isFixed
595+
? "固定付费默认按天摊销计入利润成本;纯观察或不属于当前业务时关闭。"
596+
: "默认计入:即使本站不出现在 New API 渠道列表、只存在于外层 Sub2API 的内部负载均衡中,也会按用量或余额下降计入成本。仅纯观察节点或会造成重复汇总时关闭。"}
597+
>
598+
<Checkbox>计入利润成本</Checkbox>
599+
</Form.Item>
600600
{!isFixed && (
601601
<Form.Item
602602
label="成本渠道匹配别名"

app/api/analytics/route.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export const GET = withAuth(async (request, rt) => {
7676
const heatMap = new Map(); // "weekday|hour" -> cny
7777
for (const r of heatRows) {
7878
const s = byId.get(r.stationId);
79-
if (!s) continue;
79+
if (!s || s.isOwn || s.includeInProfit === false) continue;
8080
const k = `${r.weekday}|${r.hour}`;
8181
heatMap.set(k, (heatMap.get(k) || 0) + Number(r.usd) * rateOf(s));
8282
}
@@ -123,6 +123,7 @@ export const GET = withAuth(async (request, rt) => {
123123
id: s.id,
124124
name: s.name,
125125
isOwn: !!s.isOwn,
126+
includeInProfit: s.includeInProfit !== false,
126127
cnyPerUsd: s.cnyPerUsd ?? null,
127128
totalUsd: r4(usd),
128129
totalCny: r2(usd * rateOf(s)),

db/store.js

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ export class Store {
9999
// v2.1:旧站点没有续费计划字段,默认保持原有的正常重复提醒策略。
100100
s.noRenewal = !!s.noRenewal;
101101
s.costAliases = sanitizeCostAliases(s.costAliases);
102-
s.costGateway = ["sub2api", "sub2api-password"].includes(s.type) && !!s.costGateway;
102+
// 所有监控上游默认计入利润成本;仅显式关闭的观察/重复汇总节点排除。
103+
s.includeInProfit = s.includeInProfit !== false;
104+
delete s.costGateway;
103105
if (!Array.isArray(s.fixedPurchases)) {
104106
s.fixedPurchases = [];
105107
const amount = s.fixedCostCny ?? s.fixedMonthlyCny;
@@ -290,8 +292,8 @@ export class Store {
290292
cnyPerUsd: numOrNull(input.cnyPerUsd),
291293
// 自有站渠道可能使用容器域名、内网 IP 等地址;别名参与利润成本归属匹配。
292294
costAliases: sanitizeCostAliases(input.costAliases),
293-
// 成本汇总站:其 actual_cost 已覆盖后面的负载均衡上游,利润中只计这一层
294-
costGateway: ["sub2api", "sub2api-password"].includes(input.type) && !!input.costGateway,
295+
// 即使不出现在 New API 渠道列表,也按本站用量/余额下降计入利润成本
296+
includeInProfit: input.includeInProfit !== false,
295297
// 我自己的中转站:启用「我的站点」下游用量分析(需管理员令牌)
296298
isOwn: !!input.isOwn,
297299
// 不再续费:余额低于阈值时仅提醒一次,不再发送耗尽/ETA/持续余额提醒
@@ -306,9 +308,6 @@ export class Store {
306308
alertState: null, // 告警去重状态(含不再续费站点的一次性低余额提醒时间)
307309
balance: null, // 最近一次查询结果
308310
};
309-
if (station.costGateway) {
310-
for (const s of this.data.stations) s.costGateway = false;
311-
}
312311
this.data.stations.push(station);
313312
await this.save();
314313
return station;
@@ -327,12 +326,7 @@ export class Store {
327326
if ("lowBalanceUsd" in patch) s.lowBalanceUsd = numOrNull(patch.lowBalanceUsd);
328327
if ("cnyPerUsd" in patch) s.cnyPerUsd = numOrNull(patch.cnyPerUsd);
329328
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-
}
329+
if ("includeInProfit" in patch) s.includeInProfit = patch.includeInProfit !== false;
336330
if ("isOwn" in patch) s.isOwn = !!patch.isOwn;
337331
if ("noRenewal" in patch || s.type === "fixed") {
338332
const noRenewal = s.type !== "fixed" && !!patch.noRenewal;

db/store.test.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,15 @@ test("成本渠道匹配别名会清理空值并去重", async () => {
6464
assert.deepEqual(store.get(added.id).costAliases, ["alias-a", "alias-b"]);
6565
});
6666

67-
test("全局只保留一个 Sub2API 成本汇总站", async () => {
67+
test("监控上游默认计入利润成本并可显式排除", async () => {
6868
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);
69+
const included = await store.add({ name: "负载均衡后的上游", type: "newapi", baseUrl: "https://a.example.com" });
70+
const excluded = await store.add({
71+
name: "重复汇总节点", type: "sub2api-password", baseUrl: "https://b.example.com", includeInProfit: false,
72+
});
73+
assert.equal(store.get(included.id).includeInProfit, true);
74+
assert.equal(store.get(excluded.id).includeInProfit, false);
7375

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);
76+
await store.update(excluded.id, { includeInProfit: true });
77+
assert.equal(store.get(excluded.id).includeInProfit, true);
7778
});

server/own-helpers.js

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,7 @@ export function matchCostStation(upstreams, channelUrl) {
4747
return station;
4848
}
4949

50-
// 单一成本汇总站代表完整的变量成本池:所有 New API 渠道归到它,后层上游不重复计费。
5150
export function mapCostChannels(upstreams, channels) {
52-
const gateways = upstreams.filter((s) => !!s.costGateway && ["sub2api", "sub2api-password"].includes(s.type));
53-
if (gateways.length === 1) {
54-
const gateway = gateways[0];
55-
return {
56-
gateway,
57-
matched: new Map([[gateway.id, { station: gateway, channels: channels.map((ch) => ch.name) }]]),
58-
unmatched: new Map(),
59-
};
60-
}
61-
6251
const matched = new Map();
6352
const unmatched = new Map();
6453
for (const ch of channels) {
@@ -77,7 +66,16 @@ export function mapCostChannels(upstreams, channels) {
7766
unmatched.set(label, entry);
7867
}
7968
}
80-
return { gateway: null, matched, unmatched };
69+
return { matched, unmatched };
70+
}
71+
72+
// 成本纳入与 New API 渠道匹配解耦:列表中的监控上游默认全部计入。
73+
export function selectCostUpstreams(stations, ownId) {
74+
const candidates = stations.filter((s) => s.id !== ownId);
75+
return {
76+
included: candidates.filter((s) => s.includeInProfit !== false),
77+
excluded: candidates.filter((s) => s.includeInProfit === false),
78+
};
8179
}
8280

8381
// 部分 New API 部署会对 /api/data/self 返回成功空数组。余额明确下降时不能按零成本处理。
@@ -120,10 +118,9 @@ export async function computeResold(own, incomeUsd, adminUsageUsd, startMs, endM
120118
}
121119

122120
/**
123-
* 利润 = 收入(下游消费 × 自有站售价汇率)− 成本(各匹配上游的期内成本)
124-
* 成本口径:配置了每月固定成本的上游按天摊销(月费 ÷ 30 × 窗口天数);
125-
* 否则按上游用量接口的实际扣费 × 充值汇率;用量接口不可用时退回余额下降推算。
126-
* 渠道按 base_url 与监控站点匹配(忽略协议/末尾斜杠/是否带 /api)。
121+
* 利润 = 收入(下游消费 × 自有站售价汇率)− 全部纳入成本的监控上游期内成本。
122+
* 成本纳入不依赖 New API 渠道列表:固定付费按天摊销;其余按上游实际扣费 × 充值汇率,
123+
* 用量接口返回 0 或不可用时退回余额下降推算。渠道 URL 匹配仅用于展示流量归属。
127124
*/
128125
export async function computeProfit(rt, own, incomeUsd, adminUsageUsd, { startMs, now, tz, range, resold }) {
129126
const { store, history } = rt;
@@ -135,9 +132,8 @@ export async function computeProfit(rt, own, incomeUsd, adminUsageUsd, { startMs
135132
rt._ownChannelsCache = { at: Date.now(), stationId: own.id, list: await queryOwnChannels(own) };
136133
}
137134
const channels = rt._ownChannelsCache.list;
138-
const upstreams = store.list().filter((s) => s.id !== own.id);
139-
140-
const { gateway, matched, unmatched } = mapCostChannels(upstreams, channels);
135+
const { included: upstreams, excluded: excludedUpstreams } = selectCostUpstreams(store.list(), own.id);
136+
const { matched, unmatched } = mapCostChannels(upstreams, channels);
141137

142138
const windowDays = (now - startMs) / 86400000;
143139
const rateOf = (s) => (s.cnyPerUsd != null && s.cnyPerUsd > 0 ? s.cnyPerUsd : 1);
@@ -172,15 +168,17 @@ export async function computeProfit(rt, own, incomeUsd, adminUsageUsd, { startMs
172168
}
173169
costs.push({
174170
stationId: s.id, name: s.name, mode: "fixed", note,
175-
channels: matched.get(s.id)?.channels || ["未匹配渠道 · 通用成本"],
171+
channels: matched.get(s.id)?.channels || ["未直接出现在 New API 渠道 · 按固定成本计费"],
176172
cny: r2(cnySum),
177173
});
178174
}
179-
// 按用量:匹配到渠道且未配置固定成本的上游
175+
// 按用量:所有纳入成本、且未配置固定成本的监控上游。
176+
// 是否出现在 New API 渠道列表只影响归属说明,不影响成本纳入。
180177
const usageCosts = await Promise.all(
181-
[...matched.values()]
182-
.filter(({ station }) => fixedPurchases(station).length === 0 && station.type !== "fixed")
183-
.map(async ({ station, channels: chNames }) => {
178+
upstreams
179+
.filter((station) => fixedPurchases(station).length === 0 && station.type !== "fixed")
180+
.map(async (station) => {
181+
const chNames = matched.get(station.id)?.channels || ["未直接出现在 New API 渠道 · 按监控数据计费"];
184182
const item = { stationId: station.id, name: station.name, channels: chNames };
185183
try {
186184
const u = await queryStationUsage(station, {
@@ -215,10 +213,10 @@ export async function computeProfit(rt, own, incomeUsd, adminUsageUsd, { startMs
215213
.filter((s, i, all) => (s.cnyPerUsd == null || s.cnyPerUsd <= 0) && all.findIndex((x) => x.id === s.id) === i)
216214
.map((s) => s.name);
217215
const warnings = [];
218-
if (unmatchedEnabled) warnings.push(`${unmatchedEnabled} 个启用渠道尚未匹配监控站,其成本未计入`);
219-
if (gateway) warnings.push(`用量成本由 ${gateway.name} 汇总,其负载均衡后的上游不重复计入`);
216+
if (unmatchedEnabled) warnings.push(`${unmatchedEnabled} 个启用渠道未直接关联监控站;全部监控上游仍已独立计入成本`);
220217
if (estimatedCosts) warnings.push(`${estimatedCosts} 个上游使用余额历史推算成本`);
221218
if (defaultRateStations.length) warnings.push(`${defaultRateStations.join("、")} 未配置汇率,当前按 1:1 折算`);
219+
if (excludedUpstreams.length) warnings.push(`${excludedUpstreams.map((s) => s.name).join("、")} 已设置为不计入利润成本`);
222220
return {
223221
incomeCny, totalCostCny,
224222
adminUsageCny: r2((adminUsageUsd || 0) * ownRate),
@@ -229,9 +227,9 @@ export async function computeProfit(rt, own, incomeUsd, adminUsageUsd, { startMs
229227
marginPct: incomeCny > 0 ? Math.round(((incomeCny - totalCostCny) / incomeCny) * 1000) / 10 : null,
230228
costs: costs.sort((a, b) => b.cny - a.cny),
231229
unmatched: unmatchedList,
232-
complete: unmatchedEnabled === 0 && defaultRateStations.length === 0,
230+
complete: defaultRateStations.length === 0,
233231
estimated: estimatedCosts > 0,
234-
costGateway: gateway ? { stationId: gateway.id, name: gateway.name } : null,
232+
excludedStationIds: excludedUpstreams.map((s) => s.id),
235233
warnings,
236234
windowDays: Math.round(windowDays * 10) / 10,
237235
};

0 commit comments

Comments
 (0)