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。
This commit is contained in:
@ -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 # ✅ 返回给前端
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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']
|
||||
|
||||
Reference in New Issue
Block a user