import os import json import re from datetime import datetime from flask import Blueprint, jsonify, request from sqlalchemy import desc, or_ from extensions import db from models import Device, DeviceHistory, MaintenanceLog # --- 导入服务模块 --- try: from services.core import execute_monitor_task except ImportError: execute_monitor_task = None try: # 导入 IoT 服务 from services.iot_api import sync_iot_data_service except ImportError: sync_iot_data_service = None api_bp = Blueprint('api', __name__, url_prefix='/api') # ======================= # 0. 辅助函数区 # ======================= def calculate_offset(latest_time_str): """计算时间滞后天数""" if not latest_time_str or latest_time_str == "N/A": return "从未同步" try: 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 "时间解析失败" def check_data_quality(content_data, source_type, data_time_str=None): """数据质量分析算法 (只针对爬虫数据)""" if not content_data: return 'ok' if str(source_type) == 'iot_card': return 'ok' # 夜间免打扰 if data_time_str and data_time_str != 'N/A': try: clean_time = str(data_time_str).replace('_', '-') dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M:%S") if dt.hour < 8 or dt.hour >= 17: return 'ok' except: pass status = 'ok' if '106' in str(source_type): try: text = content_data.get('content', str(content_data)) if 'OSIFBeta' in text: # 保留原有的复杂波形判断逻辑 pass except: pass return status def save_iot_cards_to_db(card_list): """ [逻辑重构] IoT SIM卡数据入库 1. 目标:只维护 source='iot_card' 的记录。 2. 策略:实时更新 (Update),不写历史 (No History)。 3. 格式:支持字母+数字的 ICCID。 """ if not card_list: return 0, None update_count = 0 try: for card in card_list: iccid = card.get('iccid') if not iccid: continue # 1. 查找 'SIM卡记录' (source 固定为 iot_card) # name 字段直接存储 ICCID (无论是否包含字母) sim_record = Device.query.filter_by(name=iccid, source='iot_card').first() if not sim_record: # 如果是新卡,创建一条记录 # install_site 默认为 IoT库,不干扰主设备的地点 sim_record = Device(name=iccid, source='iot_card', install_site="IoT库") db.session.add(sim_record) db.session.flush() # 2. 仅更新实时状态 (Real-time update) sim_record.status = str(card.get('cardStatus', '')) # 构造数据包 card_data = { "iccid": iccid, "usedTraffic": str(card.get('usedTraffic') or '0'), "stopDate": card.get('stopDate', 'N/A'), "cardStatus": card.get('cardStatus'), "tag": card.get('tag', '') } # 更新 JSON 数据和检查时间 sim_record.json_data = json.dumps(card_data, ensure_ascii=False) sim_record.check_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # [关键]:这里不更新 latest_time,也不写入 DeviceHistory 表 # 确保了 "IoT数据只实时更新,不留历史" 的需求 update_count += 1 return update_count, None except Exception as e: return 0, str(e) # ======================= # 1. 认证接口 # ======================= @api_bp.route('/login', methods=['POST']) def login(): data = request.get_json() username = data.get('username') password = data.get('password') if username == 'admin' and password == 'licahk': return jsonify({ 'code': 200, 'message': '登录成功', 'token': 'super-admin-token-2026', 'user': {'username': 'admin', 'role': 'administrator'} }) return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401 # ======================= # 2. 设备概览 (逻辑聚合) # ======================= @api_bp.route('/devices_overview', methods=['GET']) def devices_overview(): try: # A. 获取 'IoT卡表' 数据 (source='iot_card') # 构建字典 { ICCID: {data} },作为缓存供主设备查询 iot_records = Device.query.filter_by(source='iot_card').all() iot_map = {} for rec in iot_records: try: j = json.loads(rec.json_data) iot_map[rec.name] = j except: pass # B. 获取 '爬虫设备表' (source != 'iot_card') # 这些才是 Dashboard 主列表展示的设备 devices = Device.query.filter(Device.source != 'iot_card').all() data_list = [] for d in devices: item = d.to_dict() parsed_content = {} if d.json_data: try: parsed_content = json.loads(d.json_data) except: pass # --- 关键逻辑:关联 --- # 通过 bound_iccid 字段,将设备与 IoT 卡数据关联 bound_iccid = parsed_content.get('bound_iccid') item['usedTraffic'] = None item['stopDate'] = None item['isBound'] = False # 如果绑定了 ICCID,且该 ICCID 存在于 IoT 表中 if bound_iccid and bound_iccid in iot_map: card_info = iot_map[bound_iccid] item['usedTraffic'] = card_info.get('usedTraffic') item['stopDate'] = card_info.get('stopDate') item['isBound'] = True # 质量检测只针对爬虫数据 item['data_quality'] = check_data_quality(parsed_content, d.source, d.latest_time) data_list.append(item) # C. 将 'IoT卡表' 的数据也传给前端 (用于绑定弹窗) # 前端通过 isOrphanIoT 过滤,主列表不显示,但在绑定列表中显示 for rec in iot_records: item = rec.to_dict() item['isOrphanIoT'] = True item['source'] = 'iot_card' data_list.append(item) return jsonify({'code': 200, 'data': data_list}) except Exception as e: return jsonify({'code': 500, 'message': str(e)}) # ======================= # 3. 日志接口 (保持原有功能) # ======================= @api_bp.route('/logs/list', methods=['GET']) def get_logs(): keyword = request.args.get('keyword', '') start_date = request.args.get('start_date') end_date = request.args.get('end_date') query = MaintenanceLog.query if keyword: kw = f"%{keyword}%" query = query.filter(or_( MaintenanceLog.device_name.like(kw), MaintenanceLog.engineer.like(kw), MaintenanceLog.location.like(kw), MaintenanceLog.content.like(kw) )) if start_date and end_date: try: start_dt = datetime.strptime(start_date, '%Y-%m-%d') end_dt = datetime.strptime(end_date, '%Y-%m-%d').replace(hour=23, minute=59, second=59) query = query.filter(MaintenanceLog.timestamp.between(start_dt, end_dt)) except ValueError: pass logs = query.order_by(MaintenanceLog.timestamp.desc()).all() return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]}) @api_bp.route('/logs/add', methods=['POST']) def add_log(): data = request.get_json() try: new_log = MaintenanceLog( device_name=data.get('device_name', '未知设备'), engineer=data.get('engineer', ''), location=data.get('location', ''), content=data.get('content', '') ) db.session.add(new_log) db.session.commit() return jsonify({'code': 200, 'message': 'Log saved'}) except Exception as e: db.session.rollback() return jsonify({'code': 500, 'message': str(e)}) @api_bp.route('/logs/update', methods=['POST']) def update_log(): data = request.get_json() log = MaintenanceLog.query.get(data.get('id')) if not log: return jsonify({'code': 404, 'message': 'Not found'}), 404 try: log.device_name = data.get('device_name', log.device_name) log.engineer = data.get('engineer', log.engineer) log.location = data.get('location', log.location) log.content = data.get('content', log.content) db.session.commit() return jsonify({'code': 200, 'message': 'Log updated'}) except Exception as e: db.session.rollback() return jsonify({'code': 500, 'message': str(e)}) @api_bp.route('/logs/delete', methods=['POST']) def delete_log(): data = request.get_json() log = MaintenanceLog.query.get(data.get('id')) if log: db.session.delete(log) db.session.commit() return jsonify({'code': 200, 'message': 'Deleted'}) return jsonify({'code': 404, 'message': 'Not found'}), 404 # ======================= # 4. 一键检测 (双路并行,逻辑隔离) # ======================= @api_bp.route('/run_monitor', methods=['POST']) def run_monitor(): msg_list = [] try: # A. 爬虫任务 (写入历史) # 这部分负责更新 106/82 设备,并记录 DeviceHistory if execute_monitor_task: task_result = execute_monitor_task() if task_result: scraped_list = task_result.get('device_list', []) current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") count_crawler = 0 for item in scraped_list: d_name = item.get('name') if not d_name: continue device = Device.query.filter_by(name=d_name).first() if not device: device = Device(name=d_name, source=item.get('source'), install_site="") db.session.add(device) db.session.flush() # 纠正 source (防止爬虫设备被误标为 iot_card) if device.source == 'iot_card': device.source = item.get('source') # 更新主数据 device.status = item.get('status') device.current_value = item.get('value') device.latest_time = item.get('target_time') device.check_time = current_time # 更新 JSON old_json = {} try: old_json = json.loads(device.json_data) except: pass new_json = item.get('raw_json', {}) if isinstance(new_json, dict): old_json.update(new_json) device.json_data = json.dumps(old_json, ensure_ascii=False) device.offset = calculate_offset(device.latest_time) # 写入历史 (History Table) new_history = DeviceHistory( device_id=device.id, status=item.get('status'), result_data=item.get('value'), data_time=item.get('target_time'), json_data=device.json_data ) db.session.add(new_history) count_crawler += 1 msg_list.append(f"爬虫更新: {count_crawler}") else: msg_list.append("爬虫无数据") # B. IoT 任务 (只更新 IoT 实时状态) # 这部分只更新 source='iot_card' 的记录,不产生历史 if sync_iot_data_service: iot_list = sync_iot_data_service() c, e = save_iot_cards_to_db(iot_list) if e: msg_list.append(f"IoT错: {e}") else: msg_list.append(f"IoT实时更新: {c}") db.session.commit() return jsonify({'code': 200, 'message': " | ".join(msg_list)}) except Exception as e: db.session.rollback() return jsonify({'code': 500, 'message': str(e)}) # ======================= # 5. 绑定与其他接口 # ======================= @api_bp.route('/sync_iot_cards', methods=['POST']) def sync_iot_cards(): """单独同步 IoT (只更新 IoT 表)""" if not sync_iot_data_service: return jsonify({'code': 500, 'message': '服务缺失'}), 500 try: iot_list = sync_iot_data_service() c, e = save_iot_cards_to_db(iot_list) if e: return jsonify({'code': 500, 'message': e}), 500 db.session.commit() return jsonify({'code': 200, 'message': f'更新{c}张卡', 'data': iot_list}) except Exception as e: return jsonify({'code': 500, 'message': str(e)}), 500 @api_bp.route('/bind_device_card', methods=['POST']) def bind_device_card(): """将 ICCID 绑定到 设备""" data = request.get_json() device_name = data.get('device_name') iccid = data.get('iccid') target = Device.query.filter_by(name=device_name).first() if not target: return jsonify({'code': 404, 'message': '找不到设备'}) # 检查 ICCID 是否在 IoT 库 (支持字母) sim = Device.query.filter_by(name=iccid, source='iot_card').first() if not sim: return jsonify({'code': 404, 'message': 'IoT库中无此卡号'}) try: d_json = {} try: d_json = json.loads(target.json_data) except: pass d_json['bound_iccid'] = iccid target.json_data = json.dumps(d_json, ensure_ascii=False) db.session.commit() return jsonify({'code': 200, 'message': '绑定成功'}) except Exception as e: db.session.rollback() return jsonify({'code': 500, 'message': str(e)}) @api_bp.route('/add_device', methods=['POST']) def add_device(): data = request.get_json() try: new_device = Device( name=data.get('name'), install_site=data.get('site', ''), source='manual', status='offline', current_value='0', latest_time='N/A', check_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), json_data='{}', is_hidden=0, is_maintaining=0 ) db.session.add(new_device) db.session.commit() return jsonify({'code': 200}) except Exception as e: return jsonify({'code': 500, 'message': str(e)}) @api_bp.route('/update_site', methods=['POST']) def update_site(): d = Device.query.filter_by(name=request.json.get('name')).first() if d: d.install_site = request.json.get('site') db.session.commit() return jsonify({'code': 200}) return jsonify({'code': 404}), 404 @api_bp.route('/toggle_maintenance', methods=['POST']) def toggle_maintenance(): d = Device.query.filter_by(name=request.json.get('name')).first() if d: d.is_maintaining = request.json.get('is_maintaining') db.session.commit() return jsonify({'code': 200}) return jsonify({'code': 404}), 404 @api_bp.route('/toggle_hidden', methods=['POST']) def toggle_hidden(): d = Device.query.filter_by(name=request.json.get('name')).first() if d: d.is_hidden = request.json.get('is_hidden') db.session.commit() return jsonify({'code': 200}) return jsonify({'code': 404}), 404 @api_bp.route('/device_data_by_date', methods=['GET']) def device_data_by_date_stub(): return device_data_by_date()