Skip to content

Commit ba9d084

Browse files
committed
修复日报昨日统计窗口漂移
1 parent 273242f commit ba9d084

5 files changed

Lines changed: 66 additions & 22 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
- **通知告警**:余额偏低 / 耗尽 / 查询失败(**可配连续失败阈值与失败快速重试**)/ 恢复正常 / 预计即将耗尽(阈值可按天或小时),状态迁移触发、自动去重、可配重复提醒;支持 10 种渠道:Telegram、钉钉(加签)、企业微信、飞书(签名)、Bark、ntfy、Server酱、Resend 邮件、SMTP 邮件(零依赖客户端)、自定义 Webhook,每渠道可单独测试
1919
- **我的站点(下游分析)**:自营 new-api 站点的分时段 / 分模型 / **分用户**用量与消费,**未来 7 天消费预测**(组合模型 + conformal 区间,历史满两周自动启用周末模式识别)
2020
- **利润分析**:下游收入(普通用户消费 × 售价汇率)− 上游期内成本(用量 × 充值汇率;固定成本按天摊销);**管理员 / root 转售 Key 可标记计入收入**;未匹配渠道单独列出
21-
- **每日日报**:每天定时(服务器时区)汇总昨日经营——消费环比、收入/成本/利润、Top 模型与用户、上游余额与耗尽预警、未来 7 天预测——推送到通知渠道(邮件全文,IM 截断);支持预览与立即发送
21+
- **每日日报**:每天定时(默认北京时间,可用 `REPORT_TIME_ZONE` 覆盖)汇总昨日经营——消费环比、收入/成本/利润、Top 模型与用户、上游余额与耗尽预警、未来 7 天预测——推送到通知渠道(邮件全文,IM 截断);支持预览与立即发送
2222
- **人民币折算**:每站可配充值汇率(站点 $1 折合 ¥ 多少);金额主显人民币,站点原始余额次要展示;余额告警阈值仍按站点余额判断
2323
- **PWA**:可添加到手机主屏幕独立运行(品牌图标 + 离线壳缓存;静态资源网络优先,API 不缓存)
2424
- **面板登录**:scrypt 哈希 + HMAC 签名会话 Cookie(7 天,登录失败限流);默认 `admin / admin123`,登录后请在「设置」中修改

db/store.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { DEFAULT_RULES } from "../lib/alerts.js";
77
const DEFAULT_SETTINGS = {
88
refreshIntervalSec: 60, // 后台自动刷新间隔
99
lowBalanceUsd: 5, // 全局低余额告警阈值(美元)
10-
// 每日日报:按服务器时区定时汇总昨日「我的站点」经营情况并推送
10+
// 每日日报:默认按北京时间定时汇总昨日「我的站点」经营情况并推送
1111
dailyReport: { enabled: false, time: "09:00", channelIds: [], lastSent: null },
1212
};
1313

deploy/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ services:
1212
environment:
1313
# 「今日消耗 / 用量统计」的日期边界按此时区计算
1414
- TZ=Asia/Shanghai
15+
# 日报的统计日界线与定时发送时区(未设置时也默认 Asia/Shanghai)
16+
- REPORT_TIME_ZONE=Asia/Shanghai
1517
# ---- MySQL(外部实例,必填)----
1618
- DB_HOST=change-me
1719
- DB_PORT=3306

lib/providers.js

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ function trimBase(url) {
2626
return String(url || "").trim().replace(/\/+$/, "");
2727
}
2828

29+
// 本项目的时间窗统一使用 [startMs, endMs);new-api 的秒级参数结束值是包含式,
30+
// 因此必须减到窗口内最后一秒,避免把“今天 00:00”计入昨日汇总。
31+
function unixSecondWindow(startMs, endMs) {
32+
const start = Math.floor(startMs / 1000);
33+
const end = Math.max(start, Math.ceil(endMs / 1000) - 1);
34+
return { start, end };
35+
}
36+
2937
// 发起请求:网络错误/超时抛异常;HTTP 状态由调用方判断
3038
async function request(url, { method = "GET", headers = {}, json = null, timeoutMs = 9000 } = {}) {
3139
const ctrl = new AbortController();
@@ -369,9 +377,10 @@ function normSub2Trend(trend, tz, granularity) {
369377
async function sub2apiUsage(station, { startMs, endMs, granularity, tz, exactWindow, wantToday }) {
370378
const base = trimBase(station.baseUrl);
371379
if (!base) throw new Error("缺少站点地址");
380+
const lastMs = Math.max(startMs, endMs - 1);
372381
const params = {
373382
start_date: dateStrInTz(startMs, tz),
374-
end_date: dateStrInTz(endMs, tz),
383+
end_date: dateStrInTz(lastMs, tz),
375384
granularity,
376385
timezone: tz,
377386
};
@@ -441,7 +450,8 @@ async function newApiUsage(station, { startMs, endMs }) {
441450
const userId = String(station.userId || "").trim();
442451
if (userId) headers["New-Api-User"] = userId;
443452

444-
const qs = `start_timestamp=${Math.floor(startMs / 1000)}&end_timestamp=${Math.ceil(endMs / 1000)}`;
453+
const window = unixSecondWindow(startMs, endMs);
454+
const qs = `start_timestamp=${window.start}&end_timestamp=${window.end}`;
445455
const r = await request(`${base}/api/data/self?${qs}`, { headers });
446456
if (r.status >= 300) throw new Error(httpErrorMessage(r));
447457
if (r.body?.success === false) throw new Error(r.body?.message || "站点未开启数据看板");
@@ -490,7 +500,8 @@ export async function queryOwnData(station, startMs, endMs, kind) {
490500
if (userId) headers["New-Api-User"] = userId;
491501

492502
const path = kind === "user" ? "/api/data/users" : "/api/data/";
493-
const qs = `start_timestamp=${Math.floor(startMs / 1000)}&end_timestamp=${Math.ceil(endMs / 1000)}`;
503+
const window = unixSecondWindow(startMs, endMs);
504+
const qs = `start_timestamp=${window.start}&end_timestamp=${window.end}`;
494505
const r = await request(`${base}${path}?${qs}`, { headers, timeoutMs: 20000 });
495506
if (r.status >= 300) throw new Error(httpErrorMessage(r));
496507
if (r.body?.success === false) {
@@ -629,12 +640,13 @@ export async function queryLogStat(station, { username, tokenName, startMs, endM
629640
const userId = String(station.userId || "").trim();
630641
if (userId) headers["New-Api-User"] = userId;
631642

643+
const window = unixSecondWindow(startMs, endMs);
632644
const qs = new URLSearchParams({
633645
type: "2",
634646
username: String(username || ""),
635647
token_name: String(tokenName || ""),
636-
start_timestamp: String(Math.floor(startMs / 1000)),
637-
end_timestamp: String(Math.ceil(endMs / 1000)),
648+
start_timestamp: String(window.start),
649+
end_timestamp: String(window.end),
638650
});
639651
const r = await request(`${base}/api/log/stat?${qs}`, { headers, timeoutMs: 20000 });
640652
if (r.status >= 300) throw new Error(httpErrorMessage(r));

server/report.js

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,42 @@ import { broadcast } from "../lib/notify.js";
77
import { fmtEta } from "../lib/alerts.js";
88
import { getOwnUsers, computeResold, computeProfit } from "./own-helpers.js";
99

10+
const DEFAULT_REPORT_TIME_ZONE = "Asia/Shanghai";
11+
const configuredReportTimeZone = process.env.REPORT_TIME_ZONE || DEFAULT_REPORT_TIME_ZONE;
12+
export const REPORT_TIME_ZONE = (() => {
13+
try {
14+
new Intl.DateTimeFormat("en-US", { timeZone: configuredReportTimeZone });
15+
return configuredReportTimeZone;
16+
} catch {
17+
return DEFAULT_REPORT_TIME_ZONE;
18+
}
19+
})();
20+
21+
function shiftDateLabel(label, days) {
22+
const [year, month, day] = label.split("-").map(Number);
23+
return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
24+
}
25+
26+
// 日报日界线必须与进程/容器时区无关,否则 UTC 部署会在北京时间 08:00 才切换“昨天”。
27+
export function reportDayWindow(nowMs = Date.now(), tz = REPORT_TIME_ZONE) {
28+
const todayLabel = dateStrInTz(nowMs, tz);
29+
const dayEnd = parseDateLabel(todayLabel, tz);
30+
const dayStart = parseDateLabel(shiftDateLabel(todayLabel, -1), tz);
31+
const wideStart = parseDateLabel(shiftDateLabel(todayLabel, -34), tz);
32+
return { tz, todayLabel, dayStart, dayEnd, wideStart };
33+
}
34+
35+
export function reportClock(nowMs = Date.now(), tz = REPORT_TIME_ZONE) {
36+
const parts = new Intl.DateTimeFormat("en-GB", {
37+
timeZone: tz,
38+
hourCycle: "h23",
39+
hour: "2-digit",
40+
minute: "2-digit",
41+
}).formatToParts(new Date(nowMs));
42+
const value = (type) => parts.find((p) => p.type === type)?.value || "00";
43+
return { today: dateStrInTz(nowMs, tz), hhmm: `${value("hour")}:${value("minute")}` };
44+
}
45+
1046
const rptCny = (v) => "¥" + Number(v ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
1147
const rptTok = (n) => {
1248
n = Number(n) || 0;
@@ -21,21 +57,16 @@ const pctDelta = (cur, base) => {
2157
return `${d >= 0 ? "+" : ""}${d.toFixed(1)}%`;
2258
};
2359

24-
// 汇总昨日(服务器时区自然日)经营情况,返回 {title, text, html}
60+
// 汇总昨日(日报时区自然日)经营情况,返回 {title, text, html}
2561
export async function buildReport(rt) {
2662
const { store, history } = rt;
2763
const own = store.list().find((s) => s.isOwn && s.type === "newapi");
2864
if (!own) throw new Error("还没有标记「我的中转站」,无法生成日报");
29-
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
30-
const midnight = new Date();
31-
midnight.setHours(0, 0, 0, 0);
32-
const dayEnd = midnight.getTime();
33-
const dayStart = dayEnd - 86400000;
34-
const wideStart = dayEnd - 34 * 86400000;
65+
const { tz, dayStart, dayEnd, wideStart } = reportDayWindow();
3566
const ownRate = own.cnyPerUsd != null && own.cnyPerUsd > 0 ? own.cnyPerUsd : 1;
3667

3768
const [modelRows, userRows] = await Promise.all([
38-
queryOwnData(own, wideStart, Date.now(), "model"),
69+
queryOwnData(own, wideStart, dayEnd, "model"),
3970
queryOwnData(own, dayStart, dayEnd, "user"),
4071
]);
4172
let ownUsers = null;
@@ -53,8 +84,9 @@ export async function buildReport(rt) {
5384
return [...m.values()].sort((a, b) => b.cost - a.cost);
5485
};
5586
const yRows = modelRows.filter((r) => r.t >= dayStart && r.t < dayEnd);
87+
const yUserRows = userRows.filter((r) => r.t >= dayStart && r.t < dayEnd);
5688
const byModel = agg(yRows);
57-
const byUser = agg(userRows).map((u) => ({ ...u, isAdmin: adminSet.has(u.key) }));
89+
const byUser = agg(yUserRows).map((u) => ({ ...u, isAdmin: adminSet.has(u.key) }));
5890
const totalCost = byModel.reduce((a, m) => a + m.cost, 0);
5991
const totalTokens = byModel.reduce((a, m) => a + m.tokens, 0);
6092
const totalReqs = byModel.reduce((a, m) => a + m.requests, 0);
@@ -102,7 +134,7 @@ export async function buildReport(rt) {
102134
.reduce((a, u) => a + u.quotaUsd, 0) * ownRate;
103135

104136
const dateLabel = dateStrInTz(dayStart, tz);
105-
const dow = "日一二三四五六"[new Date(dayStart).getDay()];
137+
const dow = "日一二三四五六"[new Date(`${dateLabel}T00:00:00Z`).getUTCDay()];
106138
const L = [];
107139
L.push(`【${own.name} 日报】${dateLabel}(周${dow})`);
108140
L.push("");
@@ -272,7 +304,7 @@ function buildReportHtml(rt, d) {
272304
${section("上游余额", upRows)}
273305
${d.hasUsers ? section(`用户余额合计:${money(d.balanceTotal)}(预收 · ${d.userCount} 个用户)`, "") : ""}
274306
${outlook ? section("展望", outlook) : ""}
275-
<tr><td style="padding:16px 12px;${font}font-size:11px;color:${C.sub}">relay-monitor 每日日报 · 数据截至发送时刻</td></tr>
307+
<tr><td style="padding:16px 12px;${font}font-size:11px;color:${C.sub}">relay-monitor 每日日报 · 统计区间为 ${esc(d.dateLabel)} 00:00–24:00</td></tr>
276308
</table></td></tr></table></body></html>`;
277309
}
278310

@@ -292,15 +324,13 @@ export async function sendReport(rt, channelIds) {
292324
return { ok: true, results };
293325
}
294326

295-
// 调度:每 30 秒检查(HH:MM 命中且当天未发送)
327+
// 调度:每 30 秒按日报时区检查(HH:MM 命中且当天未发送)
296328
export function startReportScheduler(rt) {
297329
if (rt._reportTimer) clearInterval(rt._reportTimer); // HMR/重复初始化时防止双定时器
298330
rt._reportTimer = setInterval(async () => {
299331
const cfg = rt.store.settings.dailyReport;
300332
if (!cfg?.enabled || !cfg.time) return;
301-
const now = new Date();
302-
const hhmm = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
303-
const today = new Intl.DateTimeFormat("en-CA").format(now);
333+
const { hhmm, today } = reportClock();
304334
if (hhmm !== cfg.time || cfg.lastSent === today) return;
305335
cfg.lastSent = today; // 先占位,避免同一分钟重复发送
306336
await rt.store.save();

0 commit comments

Comments
 (0)