From 783c633a15aee0b1694acbe2bca8bc5ebac5468a Mon Sep 17 00:00:00 2001 From: DXC Date: Tue, 15 Sep 2026 17:10:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=B6=E6=95=88=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E4=B8=8B=E6=B2=89=E5=88=B0=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=8A=B6=E6=80=81=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E6=BC=8F=E6=8E=89=20offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:此前「滞后几天」在后端(calculate_offset)和前端(Dashboard.vue 自算 diffDays/diffHours)各有一套实现,阈值与粒度都不同,跨日必然打架 —— 昨天 23:00 的数据在次日 01:00 查看,后端说「滞后 1 天」,前端标「昨日数据」。 services/time_utils.py: - 新增 staleness_of(),统一返回 {level, days, text},level 分 unknown / ok / yesterday / lagging / severe 五级。 - 日期解析改用正则抠 YYYY-MM-DD,分隔符兼容 - / _,因此 ISO 的 2026-09-15T08:30:00Z、2026/09/15、2026_09_15 都能识别。 原来的 split(' ')[0] + strptime('%Y-%m-%d') 遇到 ISO T 或斜杠会直接返回 「时间解析失败」,而 82 爬虫的 target_time 是原样透传接口返回值的,格式 不可控。 - calculate_offset 保留为兼容入口,内部委托 staleness_of。 models.py: - to_dict() 新增 stale_level / stale_days。 - 修复状态映射:白名单原来只有 ['离线','异常','已离线'],漏了 'offline', 而 /add_device 创建手动设备写的正是 status='offline',导致手动添加的设备 被映射成 online、在面板上永远显示「在线」。改为大小写归一后比对,并纳入 unknown/未知。 实测:本地库 status 分布由 online:103/offline:2 修正为 online:99/offline:6。 --- 2_1banben/models.py | 30 +++++++--- 2_1banben/services/time_utils.py | 100 ++++++++++++++++++++++++++----- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/2_1banben/models.py b/2_1banben/models.py index 0be7ac7..a70f5f4 100644 --- a/2_1banben/models.py +++ b/2_1banben/models.py @@ -1,8 +1,8 @@ # models.py from datetime import datetime from extensions import db -# services.time_utils 只依赖 datetime,不会与 extensions/models 形成循环导入 -from services.time_utils import calculate_offset +# services.time_utils 只依赖 re/datetime,不会与 extensions/models 形成循环导入 +from services.time_utils import staleness_of class Device(db.Model): @@ -33,8 +33,20 @@ class Device(db.Model): is_whitelist = db.Column(db.Boolean, default=False) def to_dict(self): - # 统一状态映射逻辑 - api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online' + # 状态归一:中英文各种写法都要覆盖。 + # 注意 'offline' 是 /add_device 创建手动设备时写入的值,原先的白名单 + # 漏了它,导致手动添加的设备被映射成 online、在面板上永远显示“在线”。 + raw_status = str(self.status or '').strip().lower() + is_offline = raw_status in ( + '离线', '异常', '已离线', 'offline', 'unknown', '未知' + ) + api_status = 'offline' if is_offline else 'online' + + # 数据时效在读取时实时计算,不读 offset 列。 + # 该列是写入时刻的快照,采集一停摆就整体腐烂(库里停在 2026-02-06, + # offset 却仍显示“当天”)。列保留但已废弃,见 services/db_ingest.py。 + stale = staleness_of(self.latest_time) + return { 'id': self.id, 'name': self.name, @@ -48,11 +60,11 @@ class Device(db.Model): 'is_maintaining': self.is_maintaining, 'is_hidden': self.is_hidden, 'is_whitelist': self.is_whitelist, - # 滞后天数在读取时实时计算,不读 self.offset。 - # offset 列是写入时算好的"化石",采集一旦停摆就会整体腐烂 - # (例如库停在 2026-02-06,offset 却仍显示"当天")。 - 'offset': calculate_offset(self.latest_time), - 'file_count': self.file_count # ✅ 返回给前端 + # 时效三件套:前端不再自己算滞后天数,统一消费这三个字段 + 'offset': stale['text'], # 中文描述,兼容旧调用方 + 'stale_level': stale['level'], # unknown/ok/yesterday/lagging/severe + 'stale_days': stale['days'], # 滞后自然日数,unknown 时为 None + 'file_count': self.file_count # ✅ 返回给前端 } diff --git a/2_1banben/services/time_utils.py b/2_1banben/services/time_utils.py index b2bdf3b..df14042 100644 --- a/2_1banben/services/time_utils.py +++ b/2_1banben/services/time_utils.py @@ -1,25 +1,97 @@ # services/time_utils.py """ -时间辅助函数。 +数据时效判定的唯一实现。 + +此前「滞后几天」这件事在后端(calculate_offset)和前端(Dashboard.vue 里 +自算 diffDays/diffHours)各算了一套,阈值和粒度都不同,跨日必然打架: +昨天 23:00 的数据在次日 01:00 查看,后端说「滞后 1 天」,前端标「昨日数据」。 +现在统一由这里判定,前端只消费 Device.to_dict() 暴露的 +offset / stale_level / stale_days 三个字段做渲染。 刻意不依赖 flask / db / models,避免被 services.db_ingest 和 routes.api 互相引用时产生循环导入。 """ -from datetime import datetime +import re +from datetime import datetime, date + +# 视为「从未同步」的占位值 +_NEVER_SYNCED = ('', 'N/A', 'NA', 'NONE', 'NULL', 'NAN') + +# 从任意时间字符串里抠出 YYYY-MM-DD。分隔符兼容 - / _ , +# 因此 ISO 的 2026-09-15T08:30:00Z 和 2026_09_15 都能命中。 +# 滞后判定按自然日,时分秒不影响结果,所以不做完整的时间解析。 +_DATE_RE = re.compile(r'(\d{4})[-/_](\d{1,2})[-/_](\d{1,2})') + +# 超过这个自然日数算「严重滞后」 +SEVERE_THRESHOLD_DAYS = 7 + +# 判定级别 +LEVEL_UNKNOWN = 'unknown' # 从未同步 / 时间无法解析 +LEVEL_OK = 'ok' # 当天 +LEVEL_YESTERDAY = 'yesterday' # 昨天 +LEVEL_LAGGING = 'lagging' # 滞后 2~7 天 +LEVEL_SEVERE = 'severe' # 滞后 7 天以上 + + +def parse_record_date(value): + """ + 把各种格式的记录时间解析成 date,失败返回 None。 + + 兼容:2026-09-15 08:30:00 / 2026_09_15 / 2026-09-15T08:30:00Z / + 2026/09/15 08:30 / 2026-09-15 08:30 / 2026-09-15 + """ + if value is None: + return None + + text = str(value).strip() + if not text or text.upper() in _NEVER_SYNCED: + return None + + match = _DATE_RE.search(text) + if not match: + return None + + try: + return date(int(match.group(1)), int(match.group(2)), int(match.group(3))) + except ValueError: + # 例如 2026-13-45 这种越界日期 + return None + + +def staleness_of(value): + """ + 统一的滞后判定。 + + 返回 {'level': str, 'days': int|None, 'text': str} + level —— unknown / ok / yesterday / lagging / severe + days —— 滞后自然日数,unknown 时为 None + text —— 中文描述,可直接展示 + """ + if value is None or str(value).strip().upper() in _NEVER_SYNCED: + return {'level': LEVEL_UNKNOWN, 'days': None, 'text': '从未同步'} + + record_date = parse_record_date(value) + if record_date is None: + return {'level': LEVEL_UNKNOWN, 'days': None, 'text': '时间解析失败'} + + days = (datetime.now().date() - record_date).days + if days < 0: + # 数据时间在未来(设备/服务端时钟超前),按当天处理,不显示负数 + days = 0 + + if days == 0: + return {'level': LEVEL_OK, 'days': 0, 'text': '当天'} + if days == 1: + return {'level': LEVEL_YESTERDAY, 'days': 1, 'text': '滞后 1 天'} + if days <= SEVERE_THRESHOLD_DAYS: + return {'level': LEVEL_LAGGING, 'days': days, 'text': f'滞后 {days} 天'} + return {'level': LEVEL_SEVERE, 'days': days, 'text': f'滞后 {days} 天'} def calculate_offset(latest_time_str): """ - 计算时间滞后天数 - 用于前端展示设备数据是否过时 + (兼容保留) 返回滞后天数的中文描述文本。 + + 新代码请直接用 staleness_of(),需要分级时不要再从这段文本里反解。 """ - if not latest_time_str or latest_time_str == "N/A": - return "从未同步" - try: - # 兼容处理 2026_01_13 和 2026-01-13 格式 - clean = str(latest_time_str).split()[0].replace('_', '-') - target = datetime.strptime(clean, "%Y-%m-%d").date() - diff = (datetime.now().date() - target).days - return "当天" if diff == 0 else f"滞后 {diff} 天" - except: - return "时间解析失败" + return staleness_of(latest_time_str)['text']