# 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']