Files
ZDXX/2_1banben/services/time_utils.py
DXC 783c633a15 feat: 时效判定结构化下沉到后端,修复状态映射漏掉 offline
背景:此前「滞后几天」在后端(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。
2026-09-15 17:10:53 +08:00

98 lines
3.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
互相引用时产生循环导入。
"""
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(),需要分级时不要再从这段文本里反解。
"""
return staleness_of(latest_time_str)['text']