设备流量统计支持今日累计与历史累计分离 本次改动实现设备流量“今日累计”与“历史累计”分离,支持跨天自动清零与历史持久化累计。数据模型层为 Device 实体新增今日累计字段及统计日期,完善序列化与常量定义。后端服务统一处理累计逻辑,支持跨天懒清零。API 返回今日累计字段,前端类型与页面同步调整,设备列表与详情支持今日/历史累计展示与排序。文档同步更新描述。新增单元测试覆盖今日累计、跨天清零等核心逻辑,确保功能正确性。大石头 authored at 2026-09-02 22:59:01
diff --git "a/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md" "b/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md"
index bf4672e..b5d88d7 100644
--- "a/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md"
+++ "b/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md"
@@ -58,7 +58,7 @@
| 类别 | 页面 | 操作 |
|------|------|------|
| ✅ 已有排序(仅构建) | 网络连接 | pnpm build 部署 |
-| ⚠️ 补全字段 | 设备列表 | `SERVER_SORT_KEYS` 加 `totalRxBytes`/`totalTxBytes` |
+| ⚠️ 补全字段 | 设备列表 | `SERVER_SORT_KEYS` 加 `todayRxBytes`/`todayTxBytes` |
| ⚠️ 加 sort/desc 参数 | 操作日志、DNS 黑名单 | Controller 加参数传入 Pager |
| ⚠️ 改用 FindAll+Pager | DNS 查询日志、DNS 解析表 | 去掉硬编码 Search |
| ❌ FindAll→分页 | 用户管理、DHCP 池/绑定、DNS 规则、防火墙、PPPoE、QoS、WAN | `FindAll()` → `FindAll(null, pager)` + `OkPage()` |
@@ -427,7 +427,7 @@
| STAT-2c | 设备实时速率 | 前端 | ✅ | ✅ | ✅ | 新增 `GET /api/devices/traffic` 实时速率端点 + Devices 列表增加 ↓↑ 速率列 + 连接数列,3s 轮询显示。🔍审计修复(2026-09-02): 实时速度改指数平滑(RateSmoother 快升2s/慢降6s半衰,与后端同算法),无流量轮次平滑衰减而非瞬间归零;连接数列优先 3s 实时 connCount 回退 DB 快照,显示/排序口径统一。后端 RateSmootherTests + 前端 rateSmoother vitest 均通过 |
| STAT-2d | ARP 表采集(Windows) | 后端 | ✅ | ✅ | ✅ | WindowsArpScanner |
| STAT-2e | 设备流量趋势图 | 前端 | ✅ | ✅ | ✅ | `/stats/devices` 页面,ECharts 折线图,支持设备/分组/成员筛选。StatsE2ETests 覆盖页面加载+无JS错误 |
-| STAT-2f | 冗余字段 MemberId/GroupId + 历史累计 | 后端 | ✅ | ⏸️ | ✅ | DeviceStat/DeviceHourStat/DeviceDayStat 冗余 MemberId/GroupId;Device.TotalRxBytes/TotalTxBytes 逐分钟累加历史总流量,跨重启持久化,设备列表展示"累计↓/↑"。逐连接采集核心已由 ConntrackTrafficCollectorTests 覆盖;Total 累加位于 Linux 门控的 CollectDeviceStat 内,⏸️需 Linux+conntrack 环境验证 |
+| STAT-2f | 冗余字段 MemberId/GroupId + 今日/历史累计 | 后端 | ✅ | ✅ | ✅ | DeviceStat/DeviceHourStat/DeviceDayStat 冗余 MemberId/GroupId;Device.TotalRxBytes/TotalTxBytes 逐分钟累加历史总流量、TodayRxBytes/TodayTxBytes 逐分钟累加今日流量(TodayDate 跨天懒清零),均跨重启持久化。🔍调整(2026-09-02): 设备列表列由"累计↓/↑"改为"今日↓/↑"(数据 Device.TodayRxBytes/TodayTxBytes,支持服务端排序),历史累计移至设备详情抽屉展示;DeviceController 投影 todayRxBytes/todayTxBytes。累加/跨天逻辑抽为 StatCollector.AccumulateTraffic,DeviceTodayTrafficTests 4 例覆盖;CollectDeviceStat 逐分钟循环仍⏸️需 Linux+conntrack 环境验证 |
| STAT-5a | 网卡跨平台采集 | 后端 | ✅ | ✅ | ✅ | NetworkInterfaceStatProvider:NetworkInterface API 采集 Rx/Tx 字节/包/错误/丢弃 8 项跨平台指标(原 16 项含 Linux 专属硬件字段已精简) |
| STAT-5b | 网卡流量趋势图 | 前端 | ✅ | ✅ | ✅ | `/stats/interfaces` 页面,ECharts 多曲线图。StatsE2ETests 覆盖页面加载+无JS错误 |
@@ -626,7 +626,7 @@
| RT-5 流量整形 QoS | 3 | 3/3 | ✅ | 🔍修复: RT-5a 下行限速、RT-5c 命令检查已修复 |
| RT-6 Windows 网络路由 | 7 | 7/7 | ✅ | RT-6d⏸️需Windows Hyper-V环境 |
| STAT-1 网口流量统计 | 5 | 5/5 | ✅ | 全部完成 |
-| STAT-2 设备流量统计 | 6 | 6/6 | ✅ | 逐连接增量采集重构修复系统性欠计 + 设备历史累计。🔍审计修复(2026-09-02): STAT-2c 实时速率平滑 + 实时连接数口径已修复 |
+| STAT-2 设备流量统计 | 6 | 6/6 | ✅ | 逐连接增量采集重构修复系统性欠计 + 设备今日/历史累计。🔍审计修复(2026-09-02): STAT-2c 实时速率平滑 + 实时连接数口径已修复。🔍调整(2026-09-02): 设备列表流量列改"今日↓/↑"(TodayRxBytes/TodayTxBytes 跨天清零),历史累计移至详情 |
| STAT-5 网卡流量统计 | 2 | 2/2 | ✅ | 网卡跨平台采集(NetworkInterface API,8 项指标) |
| STAT-3 成员流量统计 | 3 | 3/3 | ✅ | 🔍审计修复: STAT-3c 注释已补全 |
| STAT-4 数据生命周期 | 3 | 3/3 | ✅ | 全部完成 |
diff --git "a/Doc/\351\234\200\346\261\202\346\226\207\346\241\243.md" "b/Doc/\351\234\200\346\261\202\346\226\207\346\241\243.md"
index 0f65b82..370df22 100644
--- "a/Doc/\351\234\200\346\261\202\346\226\207\346\241\243.md"
+++ "b/Doc/\351\234\200\346\261\202\346\226\207\346\241\243.md"
@@ -380,7 +380,7 @@ Rainbow 可部署在 **Windows/macOS** 常开设备上,提供**系统监控、
- **STAT-2c 设备实时速率**:设备列表页 3s 轮询显示每设备的↓↑实时速率和连接数
- **STAT-2d ARP 表采集(Windows)**:WindowsArpScanner 读取 Windows ARP 表,供 Windows 平台设备发现与在线状态采集
- **STAT-2e 设备流量趋势图**:`/stats/devices` 页面,ECharts 折线图展示单设备/分组/成员的历史流量趋势,支持今天/7天/30天粒度切换,含数据明细表格
-- **STAT-2f 冗余字段与设备历史累计**:DeviceStat/DeviceHourStat/DeviceDayStat 冗余存储 MemberId/GroupId,支持按用户/分组直接筛选;每分钟将设备流量增量累加至 Device.TotalRxBytes/TotalTxBytes,跨重启持久化,设备列表展示"累计↓/↑"历史总流量(替代原今日值)
+- **STAT-2f 冗余字段与设备今日/历史累计**:DeviceStat/DeviceHourStat/DeviceDayStat 冗余存储 MemberId/GroupId,支持按用户/分组直接筛选;每分钟将设备流量增量累加至 Device.TotalRxBytes/TotalTxBytes(历史总账,跨重启持久化)与 TodayRxBytes/TodayTxBytes(今日累计,TodayDate 跨天自动清零);设备列表展示"今日↓/↑"当日累计流量,设备详情展示"今日下行/上行 + 累计下行/上行"
#### STAT-5 网卡流量统计
- **STAT-5a 跨平台采集**:通过 NetworkInterface API 采集网口累计计数器(Rx/Tx 字节/包/错误/丢弃),逐分钟差值入库,Linux/Windows/macOS 统一实现
diff --git a/Rainbow.Data/Network/Model.xml b/Rainbow.Data/Network/Model.xml
index 2508f9b..60e4bd6 100644
--- a/Rainbow.Data/Network/Model.xml
+++ b/Rainbow.Data/Network/Model.xml
@@ -60,6 +60,9 @@
<Column Name="Traffic" DataType="Int64" Description="流量。今日流量(字节)" />
<Column Name="TotalRxBytes" DataType="Int64" Description="历史累计接收字节。跨重启持久化,每分钟累加设备接收增量,设备级历史流量总账" />
<Column Name="TotalTxBytes" DataType="Int64" Description="历史累计发送字节。跨重启持久化,每分钟累加设备发送增量" />
+ <Column Name="TodayRxBytes" DataType="Int64" Description="今日接收字节。当日累计接收流量,跨天自动清零" />
+ <Column Name="TodayTxBytes" DataType="Int64" Description="今日发送字节。当日累计发送流量,跨天自动清零" />
+ <Column Name="TodayDate" DataType="DateTime" Description="今日统计日期。最后一次清零今日流量的日期,跨天自动重置" />
<Column Name="Logins" DataType="Int32" Description="上线次数。累计上线次数" />
<Column Name="Enable" DataType="Boolean" Description="启用。拉黑时禁用" />
<Column Name="CreateUserId" DataType="Int32" Description="创建用户" Category="扩展" />
diff --git "a/Rainbow.Data/Network/\347\273\210\347\253\257\350\256\276\345\244\207.cs" "b/Rainbow.Data/Network/\347\273\210\347\253\257\350\256\276\345\244\207.cs"
index 3a460e7..dd64cda 100644
--- "a/Rainbow.Data/Network/\347\273\210\347\253\257\350\256\276\345\244\207.cs"
+++ "b/Rainbow.Data/Network/\347\273\210\347\253\257\350\256\276\345\244\207.cs"
@@ -164,6 +164,30 @@ public partial class Device
[BindColumn("TotalTxBytes", "历史累计发送字节。跨重启持久化,每分钟累加设备发送增量", "")]
public Int64 TotalTxBytes { get => _TotalTxBytes; set { if (OnPropertyChanging("TotalTxBytes", value)) { _TotalTxBytes = value; OnPropertyChanged("TotalTxBytes"); } } }
+ private Int64 _TodayRxBytes;
+ /// <summary>今日接收字节。当日累计接收流量,跨天自动清零</summary>
+ [DisplayName("今日接收字节")]
+ [Description("今日接收字节。当日累计接收流量,跨天自动清零")]
+ [DataObjectField(false, false, false, 0)]
+ [BindColumn("TodayRxBytes", "今日接收字节。当日累计接收流量,跨天自动清零", "")]
+ public Int64 TodayRxBytes { get => _TodayRxBytes; set { if (OnPropertyChanging("TodayRxBytes", value)) { _TodayRxBytes = value; OnPropertyChanged("TodayRxBytes"); } } }
+
+ private Int64 _TodayTxBytes;
+ /// <summary>今日发送字节。当日累计发送流量,跨天自动清零</summary>
+ [DisplayName("今日发送字节")]
+ [Description("今日发送字节。当日累计发送流量,跨天自动清零")]
+ [DataObjectField(false, false, false, 0)]
+ [BindColumn("TodayTxBytes", "今日发送字节。当日累计发送流量,跨天自动清零", "")]
+ public Int64 TodayTxBytes { get => _TodayTxBytes; set { if (OnPropertyChanging("TodayTxBytes", value)) { _TodayTxBytes = value; OnPropertyChanged("TodayTxBytes"); } } }
+
+ private DateTime _TodayDate;
+ /// <summary>今日统计日期。最后一次清零今日流量的日期,跨天自动重置</summary>
+ [DisplayName("今日统计日期")]
+ [Description("今日统计日期。最后一次清零今日流量的日期,跨天自动重置")]
+ [DataObjectField(false, false, true, 0)]
+ [BindColumn("TodayDate", "今日统计日期。最后一次清零今日流量的日期,跨天自动重置", "")]
+ public DateTime TodayDate { get => _TodayDate; set { if (OnPropertyChanging("TodayDate", value)) { _TodayDate = value; OnPropertyChanged("TodayDate"); } } }
+
private Int32 _Logins;
/// <summary>上线次数。累计上线次数</summary>
[DisplayName("上线次数")]
@@ -293,6 +317,9 @@ public partial class Device
"Traffic" => _Traffic,
"TotalRxBytes" => _TotalRxBytes,
"TotalTxBytes" => _TotalTxBytes,
+ "TodayRxBytes" => _TodayRxBytes,
+ "TodayTxBytes" => _TodayTxBytes,
+ "TodayDate" => _TodayDate,
"Logins" => _Logins,
"Enable" => _Enable,
"CreateUserId" => _CreateUserId,
@@ -328,6 +355,9 @@ public partial class Device
case "Traffic": _Traffic = value.ToLong(); break;
case "TotalRxBytes": _TotalRxBytes = value.ToLong(); break;
case "TotalTxBytes": _TotalTxBytes = value.ToLong(); break;
+ case "TodayRxBytes": _TodayRxBytes = value.ToLong(); break;
+ case "TodayTxBytes": _TodayTxBytes = value.ToLong(); break;
+ case "TodayDate": _TodayDate = value.ToDateTime(); break;
case "Logins": _Logins = value.ToInt(); break;
case "Enable": _Enable = value.ToBoolean(); break;
case "CreateUserId": _CreateUserId = value.ToInt(); break;
@@ -524,6 +554,15 @@ public partial class Device
/// <summary>历史累计发送字节。跨重启持久化,每分钟累加设备发送增量</summary>
public static readonly Field TotalTxBytes = FindByName("TotalTxBytes");
+ /// <summary>今日接收字节。当日累计接收流量,跨天自动清零</summary>
+ public static readonly Field TodayRxBytes = FindByName("TodayRxBytes");
+
+ /// <summary>今日发送字节。当日累计发送流量,跨天自动清零</summary>
+ public static readonly Field TodayTxBytes = FindByName("TodayTxBytes");
+
+ /// <summary>今日统计日期。最后一次清零今日流量的日期,跨天自动重置</summary>
+ public static readonly Field TodayDate = FindByName("TodayDate");
+
/// <summary>上线次数。累计上线次数</summary>
public static readonly Field Logins = FindByName("Logins");
@@ -617,6 +656,15 @@ public partial class Device
/// <summary>历史累计发送字节。跨重启持久化,每分钟累加设备发送增量</summary>
public const String TotalTxBytes = "TotalTxBytes";
+ /// <summary>今日接收字节。当日累计接收流量,跨天自动清零</summary>
+ public const String TodayRxBytes = "TodayRxBytes";
+
+ /// <summary>今日发送字节。当日累计发送流量,跨天自动清零</summary>
+ public const String TodayTxBytes = "TodayTxBytes";
+
+ /// <summary>今日统计日期。最后一次清零今日流量的日期,跨天自动重置</summary>
+ public const String TodayDate = "TodayDate";
+
/// <summary>上线次数。累计上线次数</summary>
public const String Logins = "Logins";
diff --git a/Rainbow.Data/Services/StatCollector.cs b/Rainbow.Data/Services/StatCollector.cs
index 239835c..1ad4ad7 100644
--- a/Rainbow.Data/Services/StatCollector.cs
+++ b/Rainbow.Data/Services/StatCollector.cs
@@ -252,11 +252,9 @@ public class StatCollector : IHostedService
if (t != null) connCounts[device.IP] = t.ConnCount;
- // 累加设备级历史累计流量(跨重启持久化,仅在有流量时更新以减少写入)
- if (rx > 0 || tx > 0)
+ // 累加设备级今日/历史累计流量(跨重启持久化;今日值跨天清零。仅在有流量或跨天重置时返回 true 触发保存)
+ if (AccumulateTraffic(device, rx, tx, DateTime.Today))
{
- device.TotalRxBytes += rx;
- device.TotalTxBytes += tx;
device.Update();
}
}
@@ -270,6 +268,36 @@ public class StatCollector : IHostedService
SyncDeviceOnline(onlineDevices, connCounts, now);
}
+ /// <summary>累加设备今日/历史累计流量,今日值跨天自动清零</summary>
+ /// <remarks>
+ /// 今日累计(TodayRxBytes/TodayTxBytes)供设备列表"今日↓/↑"列展示与服务端排序,跨天懒重置(统计日期不是今天时先归零再累加);
+ /// 历史累计(TotalRxBytes/TotalTxBytes)为全时段总账,永不归零。仅在有流量或跨天重置时返回 true,由调用方决定是否保存。
+ /// </remarks>
+ /// <param name="device">设备实体</param>
+ /// <param name="rx">本分钟接收字节增量</param>
+ /// <param name="tx">本分钟发送字节增量</param>
+ /// <param name="today">今日日期,用于跨天判定(可空,默认取当前日期)</param>
+ /// <returns>是否有变化需要保存</returns>
+ public static Boolean AccumulateTraffic(Device device, Int64 rx, Int64 tx, DateTime? today = null)
+ {
+ var now = (today ?? DateTime.Today).Date;
+ var dateChanged = device.TodayDate.Date != now;
+ if (dateChanged)
+ {
+ device.TodayRxBytes = 0;
+ device.TodayTxBytes = 0;
+ device.TodayDate = now;
+ }
+
+ if (rx <= 0 && tx <= 0 && !dateChanged) return false;
+
+ device.TodayRxBytes += rx;
+ device.TodayTxBytes += tx;
+ device.TotalRxBytes += rx;
+ device.TotalTxBytes += tx;
+ return true;
+ }
+
/// <summary>统计指定分钟窗口内每 IP 的 DNS 查询次数。来自 dnsmasq 日志解析表 DnsQueryLog,best-effort</summary>
/// <param name="start">窗口起(含)</param>
/// <param name="end">窗口止(不含)</param>
diff --git a/Rainbow.Web/Controllers/DeviceController.cs b/Rainbow.Web/Controllers/DeviceController.cs
index dd2db02..34f5855 100644
--- a/Rainbow.Web/Controllers/DeviceController.cs
+++ b/Rainbow.Web/Controllers/DeviceController.cs
@@ -438,9 +438,12 @@ public class DeviceController : ControllerBase
interfaceName = o?.Interface ?? "",
onlineRxBytes = o?.RxBytes ?? 0L,
onlineTxBytes = o?.TxBytes ?? 0L,
- // 设备级历史累计流量(跨重启持久化,供设备列表/详情展示)
+ // 设备级历史累计流量(跨重启持久化,供设备详情展示)
totalRxBytes = d.TotalRxBytes,
totalTxBytes = d.TotalTxBytes,
+ // 设备级今日累计流量(跨天清零,供设备列表"今日↓/↑"列展示与服务端排序)
+ todayRxBytes = d.TodayRxBytes,
+ todayTxBytes = d.TodayTxBytes,
};
}).ToList();
}
diff --git a/Web/src/components/devices/DeviceDetailDrawer.tsx b/Web/src/components/devices/DeviceDetailDrawer.tsx
index 003b785..27b84b9 100644
--- a/Web/src/components/devices/DeviceDetailDrawer.tsx
+++ b/Web/src/components/devices/DeviceDetailDrawer.tsx
@@ -370,8 +370,10 @@ export function DeviceDetailDrawer({ deviceId, onClose, onSaved }: DeviceDetailD
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5">
<StatItem label="最后在线" value={fmtTime(device.lastOnline)} />
<StatItem label="在线时长" value={fmtDuration(device.onlineTime)} />
- <StatItem label="今日下行" value={fmtBytes(device.rxBytes)} />
- <StatItem label="今日上行" value={fmtBytes(device.txBytes)} />
+ <StatItem label="今日下行" value={fmtBytes(device.todayRxBytes)} />
+ <StatItem label="今日上行" value={fmtBytes(device.todayTxBytes)} />
+ <StatItem label="累计下行" value={fmtBytes(device.totalRxBytes)} />
+ <StatItem label="累计上行" value={fmtBytes(device.totalTxBytes)} />
<StatItem label="上线次数" value={device.logins != null ? `${device.logins}次` : '-'} />
<StatItem label="首次发现" value={fmtTime(device.createTime)} />
<StatItem label="最后更新" value={fmtTime(device.updateTime)} />
diff --git a/Web/src/lib/api.ts b/Web/src/lib/api.ts
index 72298cc..51fa4ad 100644
--- a/Web/src/lib/api.ts
+++ b/Web/src/lib/api.ts
@@ -522,7 +522,7 @@ export function exportLogs(format: 'csv' | 'json' = 'csv', action?: string, days
export interface SystemStats { cpuUsage: number; memoryUsage: number; diskUsage: number; uptimeSeconds: number; connectionCount: number; rxSpeed: number; txSpeed: number; }
export interface SystemInfo { os: string; hostName: string; kernelVersion: string; isLinux: boolean; platform?: string; capabilities?: Record<string, boolean>; osVersion?: string; dotNetVersion?: string; }
export interface TrafficItem { name: string; rawName?: string; rxSpeed: number; txSpeed: number; rxBytes: number; txBytes: number; }
-export interface Device { id: number; mac: string; ip: string; hostName: string; name: string; online: boolean; enable: boolean; staticIP: boolean; lastOnline: string; vendor?: string; kind?: number; owner?: string; groupId?: number; memberId?: number; memberName?: string; connections?: number; rxBytes?: number; txBytes?: number; totalRxBytes?: number; totalTxBytes?: number; interfaceName: string; icon?: string; remark?: string; onlineTime?: number; traffic?: number; logins?: number; createTime?: string; updateTime?: string; }
+export interface Device { id: number; mac: string; ip: string; hostName: string; name: string; online: boolean; enable: boolean; staticIP: boolean; lastOnline: string; vendor?: string; kind?: number; owner?: string; groupId?: number; memberId?: number; memberName?: string; connections?: number; rxBytes?: number; txBytes?: number; totalRxBytes?: number; totalTxBytes?: number; todayRxBytes?: number; todayTxBytes?: number; interfaceName: string; icon?: string; remark?: string; onlineTime?: number; traffic?: number; logins?: number; createTime?: string; updateTime?: string; }
export interface DeviceGroup { id: number; name: string; parentId: number; remark?: string; }
export interface WanInterface { id: number; name: string; displayName?: string; kind: number; ip: string; gateway: string; weight: number; isDefault: boolean; enable: boolean; rxSpeed: number; txSpeed: number; }
export interface PppoeStatus { isConnected: boolean; publicIp: string; connectedSeconds: number; localIp: string; remoteIp: string; interfaceName: string; }
diff --git a/Web/src/pages/Devices.tsx b/Web/src/pages/Devices.tsx
index 54a9187..58d4ce2 100644
--- a/Web/src/pages/Devices.tsx
+++ b/Web/src/pages/Devices.tsx
@@ -155,8 +155,8 @@ export function Devices() {
/** 服务端支持的排序字段(rxSpeed/txSpeed/connCount 为客户端计算,不发送到 API) */
// 服务端实体属性名为 PascalCase,前端用 camelCase 发送后 XCode 无法识别含大写驼峰字段
- // 经验证 name/ip/hostName/online/mac/totalRxBytes/totalTxBytes 可服务端排序,rxBytes/txBytes/connections 需客户端排序
- const SERVER_SORT_KEYS = new Set(['name', 'ip', 'hostName', 'online', 'mac', 'totalRxBytes', 'totalTxBytes'])
+ // 经验证 name/ip/hostName/online/mac/todayRxBytes/todayTxBytes 可服务端排序,rxSpeed/txSpeed/connections 需客户端排序
+ const SERVER_SORT_KEYS = new Set(['name', 'ip', 'hostName', 'online', 'mac', 'todayRxBytes', 'todayTxBytes'])
/** 从 API 加载设备和分组数据(服务器端分页+筛选+排序) */
const fetchData = useCallback(async (pg: number, st: StatusTab, gid: number | null, srch: string, srt: SortState) => {
@@ -455,23 +455,25 @@ export function Devices() {
},
},
{
- key: 'totalRxBytes',
- label: '累计↓',
+ key: 'todayRxBytes',
+ label: '今日↓',
sortable: true,
width: 80,
className: 'font-mono text-xs text-right',
- render: (d) => d.totalRxBytes != null && d.totalRxBytes > 0
- ? <span className="text-[var(--color-info)]">{formatBytes(d.totalRxBytes)}</span>
+ title: '今日累计下行流量',
+ render: (d) => d.todayRxBytes != null && d.todayRxBytes > 0
+ ? <span className="text-[var(--color-info)]">{formatBytes(d.todayRxBytes)}</span>
: <span className="text-[var(--color-text-tertiary)]">-</span>,
},
{
- key: 'totalTxBytes',
- label: '累计↑',
+ key: 'todayTxBytes',
+ label: '今日↑',
sortable: true,
width: 80,
className: 'font-mono text-xs text-right',
- render: (d) => d.totalTxBytes != null && d.totalTxBytes > 0
- ? <span className="text-[var(--color-warning)]">{formatBytes(d.totalTxBytes)}</span>
+ title: '今日累计上行流量',
+ render: (d) => d.todayTxBytes != null && d.todayTxBytes > 0
+ ? <span className="text-[var(--color-warning)]">{formatBytes(d.todayTxBytes)}</span>
: <span className="text-[var(--color-text-tertiary)]">-</span>,
},
{
diff --git a/XUnitTest/Rainbow.Services/DeviceTodayTrafficTests.cs b/XUnitTest/Rainbow.Services/DeviceTodayTrafficTests.cs
new file mode 100644
index 0000000..710b798
--- /dev/null
+++ b/XUnitTest/Rainbow.Services/DeviceTodayTrafficTests.cs
@@ -0,0 +1,91 @@
+using Rainbow.Entity;
+using Rainbow.Services;
+using Xunit;
+
+namespace XUnitTest.Rainbow.Services;
+
+/// <summary>设备今日/历史累计流量(Device.TodayRxBytes/TodayTxBytes/TodayDate + StatCollector.AccumulateTraffic)测试</summary>
+/// <remarks>
+/// 纯内存逻辑测试,不依赖数据库(规避测试共享 SQLite 的不稳定)。验证:今日累计逐分钟累加、
+/// 跨天懒清零、历史累计永不清零、无流量且同日不触发保存等核心逻辑。
+/// </remarks>
+public class DeviceTodayTrafficTests
+{
+ /// <summary>构造一个仅内存的测试设备(不落库)</summary>
+ private static Device NewDevice(Int64 todayRx, Int64 todayTx, Int64 totalRx, Int64 totalTx, DateTime todayDate) => new()
+ {
+ Name = "today-traffic-test",
+ Mac = "DA:DB:00:00:00:01",
+ Enable = true,
+ TodayRxBytes = todayRx,
+ TodayTxBytes = todayTx,
+ TotalRxBytes = totalRx,
+ TotalTxBytes = totalTx,
+ TodayDate = todayDate,
+ };
+
+ [Fact(DisplayName = "今日累计_逐分钟累加今日与历史字节")]
+ public void Accumulate_AddsTodayAndTotal()
+ {
+ var today = DateTime.Today;
+ var device = NewDevice(0, 0, 0, 0, today);
+
+ // 第一分钟:下行 100 上行 10
+ Assert.True(StatCollector.AccumulateTraffic(device, 100, 10, today));
+ // 第二分钟:下行 200 上行 20(同日不再清零)
+ Assert.True(StatCollector.AccumulateTraffic(device, 200, 20, today));
+
+ Assert.Equal(300, device.TodayRxBytes); // 今日累计 100+200
+ Assert.Equal(30, device.TodayTxBytes); // 今日累计 10+20
+ Assert.Equal(300, device.TotalRxBytes); // 历史累计同值
+ Assert.Equal(30, device.TotalTxBytes);
+ Assert.Equal(today, device.TodayDate.Date);
+ }
+
+ [Fact(DisplayName = "今日累计_跨天自动清零后再累加,历史累计不清零")]
+ public void Accumulate_CrossDayResetsTodayKeepsTotal()
+ {
+ var yesterday = DateTime.Today.AddDays(-1);
+ var today = DateTime.Today;
+ // 昨天的累计值:今日 500/300,历史总账 500/300(昨日流量已计入总账)
+ var device = NewDevice(500, 300, 500, 300, yesterday);
+
+ // 今天第一分钟产生新流量 100/20
+ Assert.True(StatCollector.AccumulateTraffic(device, 100, 20, today));
+
+ Assert.Equal(100, device.TodayRxBytes); // 昨日 500 已清零,仅今日 100
+ Assert.Equal(20, device.TodayTxBytes); // 昨日 300 已清零,仅今日 20
+ Assert.Equal(600, device.TotalRxBytes); // 历史总账保留 500+100
+ Assert.Equal(320, device.TotalTxBytes); // 历史总账保留 300+20
+ Assert.Equal(today, device.TodayDate.Date);
+ }
+
+ [Fact(DisplayName = "今日累计_同日无流量返回 false 不触发保存")]
+ public void Accumulate_SameDayNoTraffic_ReturnsFalse()
+ {
+ var today = DateTime.Today;
+ var device = NewDevice(100, 50, 100, 50, today);
+
+ var changed = StatCollector.AccumulateTraffic(device, 0, 0, today);
+
+ Assert.False(changed);
+ Assert.Equal(100, device.TodayRxBytes);
+ Assert.Equal(50, device.TodayTxBytes);
+ }
+
+ [Fact(DisplayName = "今日累计_跨天无流量也归零并返回 true 触发保存")]
+ public void Accumulate_CrossDayNoTraffic_ResetsAndReturnsTrue()
+ {
+ var yesterday = DateTime.Today.AddDays(-1);
+ var today = DateTime.Today;
+ var device = NewDevice(500, 300, 500, 300, yesterday);
+
+ var changed = StatCollector.AccumulateTraffic(device, 0, 0, today);
+
+ Assert.True(changed); // 跨天重置需落库
+ Assert.Equal(0, device.TodayRxBytes);
+ Assert.Equal(0, device.TodayTxBytes);
+ Assert.Equal(today, device.TodayDate.Date);
+ Assert.Equal(500, device.TotalRxBytes); // 历史累计不受影响
+ }
+}