Skip to content

Commit 011870c

Browse files
lettimepassbyclaude
andcommitted
feat(alerts): 每类告警可单独选择推送渠道
- 规则新增 channelsFor(low/exhaust/error/recover/eta),空 = 所有 启用渠道,与日报 channelIds 同语义,旧数据自动补齐 - 告警引擎按事件键过滤目标渠道;恢复通知走 recover 绑定 - 删除渠道时同步清理各告警绑定与日报渠道里的死 id - 通知页每条规则行加渠道多选(选择即落库、按名称搜索、 responsive 折叠、停用渠道标注) - updateRules 仅接受存在的渠道 id,字段级合并 - 新增 6 个单元测试(绑定过滤 / 停用渠道 / id 校验 / 删除清理 / 默认对象不共享) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8f82f1c commit 011870c

6 files changed

Lines changed: 190 additions & 18 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
- **用量统计页**:分站点、分模型、分时段的 Token 消耗——今天 / 近 24 小时 / 近 7 天 / 近 30 天,含消耗趋势图、分模型排行与明细表
1616
- **Sub2API 账号密码模式**:只填邮箱 + 密码,面板自动登录换取令牌;令牌过期自动刷新(支持轮换),刷新失败自动重新登录——**全程无需人工干预**
1717
- **余额预测**:余额历史(30 天)+ 实时速率分层估计(近 3 小时优先)预计耗尽时间;点击站点查看趋势图(历史折线 + 耗尽投影)
18-
- **通知告警**:余额偏低 / 耗尽 / 查询失败(**可配连续失败阈值与失败快速重试**)/ 恢复正常 / 预计即将耗尽(阈值可按天或小时),状态迁移触发、自动去重、可配重复提醒;可将单站标记为**不再续费**,低余额仅提醒一次;支持 10 种渠道:Telegram、钉钉(加签)、企业微信、飞书(签名)、Bark、ntfy、Server酱、Resend 邮件、SMTP 邮件(零依赖客户端)、自定义 Webhook,每渠道可单独测试
18+
- **通知告警**:余额偏低 / 耗尽 / 查询失败(**可配连续失败阈值与失败快速重试**)/ 恢复正常 / 预计即将耗尽(阈值可按天或小时),状态迁移触发、自动去重、可配重复提醒;**每类告警可单独选择推送渠道**(不选 = 所有启用渠道;删除渠道自动清理绑定);可将单站标记为**不再续费**,低余额仅提醒一次;支持 10 种渠道:Telegram、钉钉(加签)、企业微信、飞书(签名)、Bark、ntfy、Server酱、Resend 邮件、SMTP 邮件(零依赖客户端)、自定义 Webhook,每渠道可单独测试
1919
- **我的站点(下游分析)**:自营 new-api 站点的分时段 / 分模型 / **分用户**用量与消费,**未来 7 天消费预测**(组合模型 + conformal 区间,历史满两周自动启用周末模式识别)
2020
- **利润分析**:下游收入(普通用户消费 × 售价汇率)− 全部监控上游的期内成本(不要求出现在 New API 渠道列表;用量 × 充值汇率,固定成本按天摊销);用量接口返回空数据但余额确有下降时自动回退历史推算;纯观察或重复汇总节点可关闭“计入利润成本”;支持渠道匹配别名(容器域名 / 内网 IP);**管理员 / root 转售 Key 可标记计入收入**;缺省汇率会明确标记利润不完整
2121
- **每日日报**:每天定时(默认北京时间,可用 `REPORT_TIME_ZONE` 覆盖)汇总昨日经营——消费环比、收入/成本/利润、Top 模型与用户、上游余额与耗尽预警、未来 7 天预测——推送到通知渠道(邮件全文,IM 截断);支持预览与立即发送

app/(dashboard)/notifications/page.tsx

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export default function NotificationsPage() {
9090
const [errThreshold, setErrThreshold] = useState("1");
9191
const [errRetry, setErrRetry] = useState("30");
9292
const [rulesSaving, setRulesSaving] = useState(false);
93+
// 每类告警的推送渠道绑定(空 = 所有启用渠道);选择即落库,与开关一致
94+
const [channelsFor, setChannelsFor] = useState<Record<string, string[]>>({});
9395

9496
// 每日日报表单
9597
const [drEnabled, setDrEnabled] = useState(false);
@@ -119,6 +121,7 @@ export default function NotificationsPage() {
119121
setRenotify(String(r?.renotifyHours ?? 24));
120122
setErrThreshold(String(r?.errorThreshold ?? 1));
121123
setErrRetry(String(r?.errorRetrySec ?? 30));
124+
setChannelsFor(r?.channelsFor || {});
122125
};
123126

124127
const syncDrForm = (s: any) => {
@@ -148,11 +151,13 @@ export default function NotificationsPage() {
148151
}, []);
149152

150153
// 重新拉取渠道数据(对照 v1 loadNotifications)
154+
// 同步渠道绑定(删除渠道时服务端会清理其中的死 id),但不动阈值输入表单
151155
const reloadChannels = async () => {
152156
const n = await api("/api/notifications");
153157
setChannels(n.channels);
154158
setRules(n.rules);
155159
setChannelTypes(n.channelTypes);
160+
setChannelsFor(n.rules?.channelsFor || {});
156161
};
157162

158163
// ---- 渠道操作 ---------------------------------------------------------------
@@ -288,6 +293,47 @@ export default function NotificationsPage() {
288293
}
289294
};
290295

296+
// 每类告警的渠道绑定:选择即落库;失败时回滚为服务端状态
297+
const saveChannelsFor = async (key: string, ids: string[]) => {
298+
setChannelsFor((cf) => ({ ...cf, [key]: ids }));
299+
try {
300+
const r = await api("/api/notifications/rules", {
301+
method: "PUT",
302+
body: { channelsFor: { [key]: ids } },
303+
});
304+
setRules(r.rules);
305+
setChannelsFor(r.rules?.channelsFor || {});
306+
} catch (e: any) {
307+
message.error(e.message);
308+
setChannelsFor(rules?.channelsFor || {});
309+
}
310+
};
311+
312+
// 告警规则行右侧:渠道多选(空 = 全部启用渠道)+ 开关
313+
const alertChannelOptions = channels.map((c: any) => ({
314+
value: c.id,
315+
label: c.enabled === false ? `${c.name}(已停用)` : c.name,
316+
}));
317+
// 普通渲染函数而非内嵌组件:避免每次渲染产生新组件类型导致 Select 重挂、
318+
// 多选下拉每选一项就被关闭
319+
const renderAlertControls = (evKey: string, ruleKey: string) => (
320+
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", justifyContent: "flex-end" }}>
321+
<Select
322+
mode="multiple"
323+
allowClear
324+
style={{ minWidth: 200, maxWidth: 320 }}
325+
placeholder="全部启用渠道"
326+
maxTagCount="responsive"
327+
optionFilterProp="label"
328+
value={channelsFor[evKey] || []}
329+
onChange={(ids) => saveChannelsFor(evKey, ids as string[])}
330+
options={alertChannelOptions}
331+
disabled={!channels.length}
332+
/>
333+
<Switch checked={!!rules[ruleKey]} onChange={() => toggleRule(ruleKey)} />
334+
</div>
335+
);
336+
291337
// 切换单位时把输入值换算过去(两个单位间必然是互换,对照 v1 rule-etaUnit onchange)
292338
const onEtaUnitChange = (u: "days" | "hours") => {
293339
const v = Number(etaVal);
@@ -395,7 +441,7 @@ export default function NotificationsPage() {
395441
title={<span style={{ whiteSpace: "nowrap", flexShrink: 0 }}>通知渠道</span>}
396442
extra={
397443
<Text type="secondary" style={{ fontSize: 12, whiteSpace: "normal", textAlign: "right" }}>
398-
告警将同时推送到所有启用的渠道
444+
在下方「告警规则」中可为每类告警单独选择推送渠道
399445
</Text>
400446
}
401447
>
@@ -451,20 +497,23 @@ export default function NotificationsPage() {
451497

452498
{/* 告警规则 */}
453499
<ProCard title="告警规则" style={{ marginTop: 16 }}>
500+
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>
501+
每类告警可单独选择推送渠道;不选 = 发送到所有启用的渠道
502+
</Text>
454503
<SetRow title="余额偏低" desc="剩余余额低于阈值时通知">
455-
<Switch checked={!!r.onLow} onChange={() => toggleRule("onLow")} />
504+
{renderAlertControls("low", "onLow")}
456505
</SetRow>
457506
<SetRow title="余额耗尽" desc="剩余余额归零时通知">
458-
<Switch checked={!!r.onExhaust} onChange={() => toggleRule("onExhaust")} />
507+
{renderAlertControls("exhaust", "onExhaust")}
459508
</SetRow>
460509
<SetRow title="查询失败" desc="接口查询出错时通知(令牌失效、站点宕机等)">
461-
<Switch checked={!!r.onError} onChange={() => toggleRule("onError")} />
510+
{renderAlertControls("error", "onError")}
462511
</SetRow>
463512
<SetRow title="恢复正常" desc="从异常状态恢复后通知">
464-
<Switch checked={!!r.onRecover} onChange={() => toggleRule("onRecover")} />
513+
{renderAlertControls("recover", "onRecover")}
465514
</SetRow>
466515
<SetRow title="耗尽预警" desc="按消耗速度预计即将耗尽时通知">
467-
<Switch checked={!!r.onEta} onChange={() => toggleRule("onEta")} />
516+
{renderAlertControls("eta", "onEta")}
468517
</SetRow>
469518
<SetRow title="耗尽预警阈值" desc="预计在该时间内耗尽则触发「耗尽预警」,可按天或小时设置">
470519
<div style={{ display: "flex", gap: 8 }}>
@@ -564,7 +613,7 @@ export default function NotificationsPage() {
564613
}
565614
destroyOnHidden
566615
>
567-
<Text type="secondary">告警触发时将推送到所有启用的渠道</Text>
616+
<Text type="secondary">默认接收所有告警;可在「告警规则」中按告警类型指定渠道</Text>
568617
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 12 }}>
569618
<div>
570619
<div style={{ marginBottom: 4 }}>名称</div>

db/store.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// 设计:内存缓存 this.data 保持 v1 数据形状,所有读走内存(同步 getter 不变),
33
// 所有写通过 save() 串行化写透 MySQL——消费方(26 个端点/告警/日报)零改动。
44
import { hashPassword } from "../lib/auth.js";
5-
import { DEFAULT_RULES } from "../lib/alerts.js";
5+
import { ALERT_EVENT_KEYS, DEFAULT_RULES } from "../lib/alerts.js";
66

77
const DEFAULT_SETTINGS = {
88
refreshIntervalSec: 60, // 后台自动刷新间隔
@@ -66,6 +66,17 @@ function asDoc(v) {
6666
return typeof v === "string" ? JSON.parse(v) : v;
6767
}
6868

69+
// 每类告警的渠道绑定:归一化为 5 个事件键齐全的新对象(渠道 id 去重、字符串化)。
70+
// 始终返回新对象,避免共享/回写 DEFAULT_RULES.channelsFor。
71+
function sanitizeChannelsFor(input, base) {
72+
const out = {};
73+
for (const k of ALERT_EVENT_KEYS) {
74+
const src = input && k in input ? input[k] : base?.[k];
75+
out[k] = Array.isArray(src) ? [...new Set(src.map(String).filter(Boolean))] : [];
76+
}
77+
return out;
78+
}
79+
6980
export class Store {
7081
constructor(pool) {
7182
this.pool = pool;
@@ -93,6 +104,9 @@ export class Store {
93104
rules: { ...DEFAULT_RULES, ...(meta.notifications?.rules || {}) },
94105
},
95106
};
107+
// v2.2:旧规则没有渠道绑定字段;归一化成 5 个事件键齐全的独立对象
108+
this.data.notifications.rules.channelsFor =
109+
sanitizeChannelsFor(this.data.notifications.rules.channelsFor, null);
96110
// 迁移:历代固定成本字段(fixedMonthlyCny / fixedCostCny+fixedDays+fixedStartDate)
97111
// 统一为付费记录数组 fixedPurchases(v1 老数据经 db/migrate.js 导入时同样适用)
98112
for (const s of this.data.stations) {
@@ -245,6 +259,16 @@ export class Store {
245259
async removeChannel(id) {
246260
const n = this.data.notifications.channels.length;
247261
this.data.notifications.channels = this.data.notifications.channels.filter((c) => c.id !== id);
262+
// 同步清理各处的渠道绑定,避免留下永远匹配不到的死 id
263+
// (绑定清空后自动回落到「所有启用渠道」的默认语义)
264+
const cf = this.data.notifications.rules.channelsFor;
265+
if (cf) {
266+
for (const k of ALERT_EVENT_KEYS) {
267+
if (Array.isArray(cf[k])) cf[k] = cf[k].filter((x) => x !== id);
268+
}
269+
}
270+
const dr = this.data.settings.dailyReport;
271+
if (Array.isArray(dr?.channelIds)) dr.channelIds = dr.channelIds.filter((x) => x !== id);
248272
await this.save();
249273
return this.data.notifications.channels.length < n;
250274
}
@@ -263,6 +287,17 @@ export class Store {
263287
if ("renotifyHours" in patch) r.renotifyHours = Math.max(0, Number(patch.renotifyHours) || 0);
264288
if ("errorThreshold" in patch) r.errorThreshold = Math.max(1, Math.floor(Number(patch.errorThreshold) || 1));
265289
if ("errorRetrySec" in patch) r.errorRetrySec = Math.max(0, Math.floor(Number(patch.errorRetrySec) || 0));
290+
if ("channelsFor" in patch && typeof patch.channelsFor === "object" && patch.channelsFor) {
291+
// 字段级合并:只更新载荷里出现的事件键;仅接受当前存在的渠道 id
292+
const valid = new Set(this.data.notifications.channels.map((c) => c.id));
293+
const cleaned = {};
294+
for (const k of ALERT_EVENT_KEYS) {
295+
if (!(k in patch.channelsFor)) continue;
296+
const v = patch.channelsFor[k];
297+
cleaned[k] = Array.isArray(v) ? v.filter((x) => valid.has(String(x))) : [];
298+
}
299+
r.channelsFor = sanitizeChannelsFor(cleaned, r.channelsFor);
300+
}
266301
await this.save();
267302
return r;
268303
}

db/store.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,42 @@ test("监控上游默认计入利润成本并可显式排除", async () => {
7676
await store.update(excluded.id, { includeInProfit: true });
7777
assert.equal(store.get(excluded.id).includeInProfit, true);
7878
});
79+
80+
test("渠道绑定:只接受存在的渠道 id,字段级合并", async () => {
81+
const store = new Store(fakePool());
82+
store.data.notifications.channels = [
83+
{ id: "ch-1", name: "A", type: "webhook", enabled: true },
84+
{ id: "ch-2", name: "B", type: "webhook", enabled: true },
85+
];
86+
const r1 = await store.updateRules({ channelsFor: { low: ["ch-1", "bogus"], eta: ["ch-2"] } });
87+
assert.deepEqual(r1.channelsFor.low, ["ch-1"]);
88+
assert.deepEqual(r1.channelsFor.eta, ["ch-2"]);
89+
assert.deepEqual(r1.channelsFor.exhaust, []);
90+
// 只更新载荷里出现的键,其余绑定保持
91+
const r2 = await store.updateRules({ channelsFor: { exhaust: ["ch-2"] } });
92+
assert.deepEqual(r2.channelsFor.low, ["ch-1"]);
93+
assert.deepEqual(r2.channelsFor.exhaust, ["ch-2"]);
94+
});
95+
96+
test("删除渠道时清理告警绑定与日报渠道里的死 id", async () => {
97+
const store = new Store(fakePool());
98+
store.data.notifications.channels = [
99+
{ id: "ch-1", name: "A", type: "webhook", enabled: true },
100+
{ id: "ch-2", name: "B", type: "webhook", enabled: true },
101+
];
102+
await store.updateRules({ channelsFor: { low: ["ch-1", "ch-2"], error: ["ch-1"] } });
103+
store.data.settings.dailyReport = { enabled: true, time: "09:00", channelIds: ["ch-1", "ch-2"], lastSent: null };
104+
105+
await store.removeChannel("ch-1");
106+
assert.deepEqual(store.rules.channelsFor.low, ["ch-2"]);
107+
assert.deepEqual(store.rules.channelsFor.error, []);
108+
assert.deepEqual(store.settings.dailyReport.channelIds, ["ch-2"]);
109+
});
110+
111+
test("加载旧规则时补齐渠道绑定字段且不共享默认对象", async () => {
112+
const a = await new Store(fakePool()).load();
113+
const b = await new Store(fakePool()).load();
114+
assert.deepEqual(a.rules.channelsFor, { low: [], exhaust: [], error: [], recover: [], eta: [] });
115+
a.rules.channelsFor.low.push("x");
116+
assert.deepEqual(b.rules.channelsFor.low, []);
117+
});

lib/alerts.js

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// 告警引擎:按状态迁移触发通知,带冷却去重
22
import { broadcast } from "./notify.js";
33

4+
// 告警事件键:每类通知可单独绑定推送渠道(channelsFor)
5+
export const ALERT_EVENT_KEYS = ["low", "exhaust", "error", "recover", "eta"];
6+
47
export const DEFAULT_RULES = {
58
onLow: true, // 余额低于阈值
69
onExhaust: true, // 余额耗尽
@@ -12,8 +15,19 @@ export const DEFAULT_RULES = {
1215
renotifyHours: 24, // 同一异常状态的重复提醒间隔
1316
errorThreshold: 1, // 查询连续失败达到该次数才通知(1 = 首次失败即通知)
1417
errorRetrySec: 30, // 查询失败后隔 N 秒立即重试一次(0 = 关闭快速重试,等下次轮询)
18+
// 每类告警的推送渠道 id 列表;空数组 = 所有启用渠道(与日报 channelIds 同语义)
19+
channelsFor: { low: [], exhaust: [], error: [], recover: [], eta: [] },
1520
};
1621

22+
// 按事件键筛选目标渠道:未绑定(空数组)时发给所有启用渠道
23+
export function channelsForEvent(channels, rules, key) {
24+
const ids = rules?.channelsFor?.[key];
25+
const bound = Array.isArray(ids) ? ids : [];
26+
return (channels || []).filter(
27+
(c) => c.enabled !== false && (!bound.length || bound.includes(c.id))
28+
);
29+
}
30+
1731
// 不足一天用小时表述,避免出现「预计 0.3 天内耗尽」
1832
export function fmtEta(days) {
1933
return days >= 1 ? `${days} 天` : `${Math.max(1, Math.round(days * 24))} 小时`;
@@ -89,28 +103,31 @@ export async function evaluateStation(station, prediction, rules, channels, glob
89103
const wasBad = ["warn", "danger", "error"].includes(prev.state);
90104
const ruleFor = { warn: r.onLow, danger: r.onExhaust, error: r.onError };
91105

106+
// 每类告警按 eventKey 单独绑定推送渠道(channelsFor)
107+
const keyFor = { warn: "low", danger: "exhaust", error: "error" };
108+
92109
if (station.noRenewal) {
93110
// 不再续费的站点只发一次余额提醒:直接从正常跳到耗尽也按低余额提醒处理。
94111
// 查询失败仍按常规策略通知,避免凭证失效或站点离线被静默。
95112
const isLowBalance = rawState === "warn" || rawState === "danger";
96113
if (isLowBalance && r.onLow && !prev.noRenewalLowNotifiedAt) {
97-
notify = { title: `【低余额提醒】${station.name} 已进入不再续费阶段`, isNoRenewalLow: true };
114+
notify = { title: `【低余额提醒】${station.name} 已进入不再续费阶段`, isNoRenewalLow: true, eventKey: "low" };
98115
} else if (state === "error" && state !== prev.state && r.onError) {
99-
notify = { title: `【中转站告警】${station.name} ${STATE_LABEL.error}` };
116+
notify = { title: `【中转站告警】${station.name} ${STATE_LABEL.error}`, eventKey: "error" };
100117
} else if (state === "error" && state === prev.state && r.onError && r.renotifyHours > 0 &&
101118
now - (prev.notifiedAt || 0) > r.renotifyHours * 3600000) {
102-
notify = { title: `【持续告警】${station.name} 仍处于「${STATE_LABEL.error}」` };
119+
notify = { title: `【持续告警】${station.name} 仍处于「${STATE_LABEL.error}」`, eventKey: "error" };
103120
} else if (state === "ok" && prev.state === "error" && r.onRecover) {
104-
notify = { title: `【恢复通知】${station.name} 已恢复正常` };
121+
notify = { title: `【恢复通知】${station.name} 已恢复正常`, eventKey: "recover" };
105122
}
106123
} else {
107124
if (isBad && state !== prev.state && ruleFor[state]) {
108-
notify = { title: `【中转站告警】${station.name} ${STATE_LABEL[state]}` };
125+
notify = { title: `【中转站告警】${station.name} ${STATE_LABEL[state]}`, eventKey: keyFor[state] };
109126
} else if (isBad && state === prev.state && ruleFor[state] && r.renotifyHours > 0 &&
110127
now - (prev.notifiedAt || 0) > r.renotifyHours * 3600000) {
111-
notify = { title: `【持续告警】${station.name} 仍处于「${STATE_LABEL[state]}」` };
128+
notify = { title: `【持续告警】${station.name} 仍处于「${STATE_LABEL[state]}」`, eventKey: keyFor[state] };
112129
} else if (state === "ok" && wasBad && r.onRecover) {
113-
notify = { title: `【恢复通知】${station.name} 已恢复正常` };
130+
notify = { title: `【恢复通知】${station.name} 已恢复正常`, eventKey: "recover" };
114131
}
115132
}
116133

@@ -120,12 +137,12 @@ export async function evaluateStation(station, prediction, rules, channels, glob
120137
prediction.etaDays <= r.etaDays &&
121138
(!prev.etaNotifiedAt ||
122139
(r.renotifyHours > 0 && now - prev.etaNotifiedAt > r.renotifyHours * 3600000))) {
123-
notify = { title: `【耗尽预警】${station.name} 预计 ${fmtEta(prediction.etaDays)}内耗尽`, isEta: true };
140+
notify = { title: `【耗尽预警】${station.name} 预计 ${fmtEta(prediction.etaDays)}内耗尽`, isEta: true, eventKey: "eta" };
124141
}
125142

126143
if (notify) {
127144
const body = buildMessage(station, state, prediction);
128-
const results = await broadcast(channels, notify.title, body, {
145+
const results = await broadcast(channelsForEvent(channels, r, notify.eventKey), notify.title, body, {
129146
event: notify.isNoRenewalLow ? "warn" : notify.isEta ? "eta" : state,
130147
station: { id: station.id, name: station.name, type: station.type },
131148
remaining: station.balance?.remaining ?? null,

0 commit comments

Comments
 (0)