@@ -7,6 +7,42 @@ import { broadcast } from "../lib/notify.js";
77import { fmtEta } from "../lib/alerts.js" ;
88import { 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+
1046const rptCny = ( v ) => "¥" + Number ( v ?? 0 ) . toLocaleString ( "en-US" , { minimumFractionDigits : 2 , maximumFractionDigits : 2 } ) ;
1147const 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}
2561export 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 命中且当天未发送)
296328export 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