2026-01-09 12:48:50 +08:00
|
|
|
|
import os
|
2026-09-15 17:05:43 +08:00
|
|
|
|
import io
|
2026-01-09 12:48:50 +08:00
|
|
|
|
import sys
|
|
|
|
|
|
import json
|
|
|
|
|
|
import mimetypes
|
2026-02-03 17:15:42 +08:00
|
|
|
|
import logging
|
2026-01-09 12:48:50 +08:00
|
|
|
|
from datetime import datetime
|
2026-02-06 10:08:49 +08:00
|
|
|
|
import pytz
|
2026-02-03 17:15:42 +08:00
|
|
|
|
|
2026-01-09 12:48:50 +08:00
|
|
|
|
from flask import Flask, send_from_directory, jsonify
|
|
|
|
|
|
from flask_cors import CORS
|
|
|
|
|
|
from flask_apscheduler import APScheduler
|
|
|
|
|
|
|
2026-09-15 17:05:43 +08:00
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# ✅ 0. 控制台编码兜底(必须最先执行)
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# 保留原始流引用,防止被 GC 回收时连带关闭底层 buffer
|
|
|
|
|
|
_CONSOLE_ORIGINALS = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _force_utf8_console():
|
|
|
|
|
|
"""
|
|
|
|
|
|
强制 stdout/stderr 以 UTF-8 输出,防止 Windows GBK 终端下 emoji 崩溃。
|
|
|
|
|
|
|
|
|
|
|
|
Windows 中文控制台默认 cp936(GBK),而本应用(app.py / services.core /
|
|
|
|
|
|
crawler_106 / crawler_82)大量使用 emoji 打印日志。一旦 print 抛
|
|
|
|
|
|
UnicodeEncodeError,异常会落进采集任务的 try 块,导致整个事务被 rollback
|
|
|
|
|
|
并报"数据写入失败" —— 表现就是定时采集长期静默不落库。
|
|
|
|
|
|
"""
|
|
|
|
|
|
for name in ('stdout', 'stderr'):
|
|
|
|
|
|
stream = getattr(sys, name, None)
|
|
|
|
|
|
# PyInstaller --noconsole 或输出重定向时,stream 或其 buffer 可能不存在
|
|
|
|
|
|
if stream is None or not hasattr(stream, 'buffer'):
|
|
|
|
|
|
continue
|
|
|
|
|
|
enc = (getattr(stream, 'encoding', None) or '').lower().replace('-', '')
|
|
|
|
|
|
if enc == 'utf8':
|
|
|
|
|
|
continue
|
|
|
|
|
|
# 替换前先 flush:否则旧流缓冲区里尚未写出的内容会随旧对象一起丢掉
|
|
|
|
|
|
try:
|
|
|
|
|
|
stream.flush()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
_CONSOLE_ORIGINALS.append(stream)
|
|
|
|
|
|
setattr(sys, name, io.TextIOWrapper(stream.buffer, encoding='utf-8', errors='replace'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_force_utf8_console()
|
|
|
|
|
|
|
2026-01-09 12:48:50 +08:00
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# ✅ 1. 核心模块引用
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
try:
|
2026-01-13 14:50:23 +08:00
|
|
|
|
from config import Config
|
2026-01-09 12:48:50 +08:00
|
|
|
|
from extensions import db
|
2026-01-13 14:50:23 +08:00
|
|
|
|
from models import Device, DeviceHistory
|
2026-02-08 10:53:00 +08:00
|
|
|
|
# 引入核心爬虫调度
|
2026-01-09 12:48:50 +08:00
|
|
|
|
from services.core import execute_monitor_task
|
2026-09-15 17:05:43 +08:00
|
|
|
|
# 引入统一入库管道
|
|
|
|
|
|
from services.db_ingest import ingest_device_data
|
2026-01-09 12:48:50 +08:00
|
|
|
|
|
2026-02-06 10:08:49 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from services.iot_api import sync_iot_data_service
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
sync_iot_data_service = None
|
2026-01-13 14:50:23 +08:00
|
|
|
|
|
2026-01-09 12:48:50 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from routes.api import api_bp as device_bp
|
2026-02-03 17:15:42 +08:00
|
|
|
|
from routes.api import calculate_offset
|
2026-01-09 12:48:50 +08:00
|
|
|
|
except ImportError:
|
2026-02-08 10:53:00 +08:00
|
|
|
|
# 兜底逻辑,防止缺失 calculate_offset 导致崩溃
|
|
|
|
|
|
def calculate_offset(target_time):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from routes.api import device_bp
|
2026-01-09 12:48:50 +08:00
|
|
|
|
|
|
|
|
|
|
except ImportError as e:
|
2026-02-08 10:53:00 +08:00
|
|
|
|
print(f"❌ [启动错误] 模块导入失败: {e}")
|
2026-01-09 12:48:50 +08:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
2026-02-08 10:53:00 +08:00
|
|
|
|
# 2. 智能路径配置
|
2026-01-09 12:48:50 +08:00
|
|
|
|
# ==============================================================================
|
2026-02-06 10:08:49 +08:00
|
|
|
|
RESOURCE_BASE = Config.BASE_DIR
|
|
|
|
|
|
INSTANCE_PATH = Config.INSTANCE_DIR
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def find_static_folder(base_path):
|
|
|
|
|
|
"""
|
2026-02-08 10:53:00 +08:00
|
|
|
|
全能路径搜寻逻辑,适配 PyInstaller 打包环境
|
2026-02-06 10:08:49 +08:00
|
|
|
|
"""
|
2026-01-09 12:48:50 +08:00
|
|
|
|
if getattr(sys, 'frozen', False):
|
2026-02-06 10:08:49 +08:00
|
|
|
|
if hasattr(sys, '_MEIPASS'):
|
|
|
|
|
|
mei_path = os.path.join(sys._MEIPASS, 'web_dist')
|
|
|
|
|
|
if os.path.exists(os.path.join(mei_path, 'index.html')):
|
|
|
|
|
|
return mei_path
|
|
|
|
|
|
internal_path = os.path.join(base_path, '_internal', 'web_dist')
|
|
|
|
|
|
if os.path.exists(os.path.join(internal_path, 'index.html')):
|
|
|
|
|
|
return internal_path
|
2026-01-14 14:42:33 +08:00
|
|
|
|
|
2026-02-06 10:08:49 +08:00
|
|
|
|
path = os.path.join(base_path, 'web_dist')
|
|
|
|
|
|
if os.path.exists(os.path.join(path, 'index.html')):
|
|
|
|
|
|
return path
|
2026-01-09 12:48:50 +08:00
|
|
|
|
|
2026-02-06 10:08:49 +08:00
|
|
|
|
parent_path = os.path.join(os.path.dirname(base_path), 'web_dist')
|
|
|
|
|
|
if os.path.exists(os.path.join(parent_path, 'index.html')):
|
|
|
|
|
|
return parent_path
|
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
STATIC_FOLDER = find_static_folder(RESOURCE_BASE)
|
2026-01-09 12:48:50 +08:00
|
|
|
|
mimetypes.add_type('application/javascript', '.js')
|
|
|
|
|
|
mimetypes.add_type('text/css', '.css')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
2026-02-08 10:53:00 +08:00
|
|
|
|
# 3. 核心定时任务逻辑 (深度优化版)
|
2026-01-09 12:48:50 +08:00
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
def auto_monitor_job(app):
|
2026-02-03 17:15:42 +08:00
|
|
|
|
"""
|
2026-09-15 17:05:43 +08:00
|
|
|
|
每天的定时采集任务。
|
|
|
|
|
|
|
|
|
|
|
|
入库细节全部收敛到 services.db_ingest.ingest_device_data,本函数只负责:
|
|
|
|
|
|
建立应用上下文 -> 触发爬虫 -> 调用入库管道 -> 提交事务。
|
2026-02-03 17:15:42 +08:00
|
|
|
|
"""
|
2026-01-09 12:48:50 +08:00
|
|
|
|
with app.app_context():
|
2026-09-15 17:05:43 +08:00
|
|
|
|
# 强制清理会话,确保线程获取的是全新的数据库连接
|
2026-02-08 10:53:00 +08:00
|
|
|
|
db.session.remove()
|
|
|
|
|
|
|
2026-02-03 17:15:42 +08:00
|
|
|
|
tz = pytz.timezone('Asia/Shanghai')
|
|
|
|
|
|
now_str = datetime.now(tz).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n{'=' * 50}")
|
2026-02-08 10:53:00 +08:00
|
|
|
|
print(f"⏰ [定时任务启动] {now_str}")
|
2026-02-03 17:15:42 +08:00
|
|
|
|
|
|
|
|
|
|
if not execute_monitor_task:
|
2026-02-08 10:53:00 +08:00
|
|
|
|
print("❌ 错误: execute_monitor_task 未定义")
|
2026-02-03 17:15:42 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
task_result = execute_monitor_task()
|
|
|
|
|
|
|
|
|
|
|
|
if not task_result:
|
2026-02-06 10:08:49 +08:00
|
|
|
|
print("⚠️ [警告] 爬虫执行完毕,但返回空数据")
|
2026-02-03 17:15:42 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
scraped_list = task_result.get('device_list', [])
|
2026-02-06 10:08:49 +08:00
|
|
|
|
print(f"📦 [数据获取] 爬取到 {len(scraped_list)} 条设备数据")
|
2026-02-03 17:15:42 +08:00
|
|
|
|
|
2026-09-15 17:05:43 +08:00
|
|
|
|
updated, history = ingest_device_data(scraped_list)
|
|
|
|
|
|
|
2026-02-03 17:15:42 +08:00
|
|
|
|
db.session.commit()
|
2026-09-15 17:05:43 +08:00
|
|
|
|
print(f"✅ [入库成功] 设备更新: {updated} | 历史追加: {history}")
|
2026-02-03 17:15:42 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-02-06 10:08:49 +08:00
|
|
|
|
db.session.rollback()
|
2026-02-05 09:25:00 +08:00
|
|
|
|
print(f"❌ [严重异常] 数据写入失败: {e}")
|
2026-02-08 10:53:00 +08:00
|
|
|
|
import traceback
|
|
|
|
|
|
traceback.print_exc()
|
2026-02-06 10:08:49 +08:00
|
|
|
|
finally:
|
2026-09-15 17:05:43 +08:00
|
|
|
|
# 再次清理 Session,防止内存泄漏或污染下一次任务
|
2026-02-06 10:08:49 +08:00
|
|
|
|
db.session.remove()
|
|
|
|
|
|
print(f"{'=' * 50}\n")
|
2026-01-09 12:48:50 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# 4. Flask 应用工厂
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
def create_app():
|
2026-02-06 10:08:49 +08:00
|
|
|
|
print(f"🔍 [前端路径锁定] {STATIC_FOLDER}")
|
|
|
|
|
|
|
2026-01-14 14:42:33 +08:00
|
|
|
|
app = Flask(__name__, static_folder=STATIC_FOLDER, instance_path=INSTANCE_PATH)
|
2026-01-09 12:48:50 +08:00
|
|
|
|
CORS(app)
|
|
|
|
|
|
|
2026-01-14 14:42:33 +08:00
|
|
|
|
if not os.path.exists(app.instance_path):
|
2026-02-03 17:15:42 +08:00
|
|
|
|
os.makedirs(app.instance_path, exist_ok=True)
|
2026-01-09 12:48:50 +08:00
|
|
|
|
|
2026-01-13 14:50:23 +08:00
|
|
|
|
app.config.from_object(Config)
|
2026-02-08 10:53:00 +08:00
|
|
|
|
|
|
|
|
|
|
# 初始化 DB
|
2026-01-09 12:48:50 +08:00
|
|
|
|
db.init_app(app)
|
|
|
|
|
|
|
2026-02-08 10:53:00 +08:00
|
|
|
|
# 初始化调度器
|
2026-01-09 12:48:50 +08:00
|
|
|
|
scheduler = APScheduler()
|
|
|
|
|
|
scheduler.init_app(app)
|
|
|
|
|
|
scheduler.start()
|
|
|
|
|
|
|
2026-02-08 10:53:00 +08:00
|
|
|
|
# --- 添加定时任务 ---
|
|
|
|
|
|
# 注意:这里我们传递 [app] 作为参数,确保 job 函数内能获取到 app 上下文
|
2026-01-09 12:48:50 +08:00
|
|
|
|
scheduler.add_job(
|
|
|
|
|
|
id='daily_monitor_task',
|
|
|
|
|
|
func=auto_monitor_job,
|
|
|
|
|
|
args=[app],
|
|
|
|
|
|
trigger='cron',
|
2026-02-06 10:08:49 +08:00
|
|
|
|
hour=17,
|
2026-02-08 10:53:00 +08:00
|
|
|
|
minute=00,
|
|
|
|
|
|
second=00,
|
2026-02-05 09:25:00 +08:00
|
|
|
|
misfire_grace_time=3600,
|
|
|
|
|
|
timezone=pytz.timezone('Asia/Shanghai')
|
2026-01-09 12:48:50 +08:00
|
|
|
|
)
|
2026-02-03 17:40:36 +08:00
|
|
|
|
print(f"📅 定时任务已锁定: 每天北京时间 17:00 执行")
|
2026-02-03 17:15:42 +08:00
|
|
|
|
|
2026-01-09 12:48:50 +08:00
|
|
|
|
app.register_blueprint(device_bp)
|
|
|
|
|
|
|
2026-02-03 17:15:42 +08:00
|
|
|
|
@app.route('/api/force_run')
|
|
|
|
|
|
def force_run_task():
|
2026-02-08 10:53:00 +08:00
|
|
|
|
"""手动触发接口:复用同一个 auto_monitor_job 函数,确保逻辑一致"""
|
2026-02-03 17:15:42 +08:00
|
|
|
|
auto_monitor_job(app)
|
2026-02-08 10:53:00 +08:00
|
|
|
|
return jsonify({'code': 200, 'msg': '手动触发成功,请查看服务器日志'})
|
2026-02-03 17:15:42 +08:00
|
|
|
|
|
2026-01-09 12:48:50 +08:00
|
|
|
|
@app.route('/')
|
|
|
|
|
|
def serve_index():
|
2026-02-06 10:08:49 +08:00
|
|
|
|
try:
|
|
|
|
|
|
return send_from_directory(app.static_folder, 'index.html')
|
|
|
|
|
|
except Exception:
|
2026-02-08 10:53:00 +08:00
|
|
|
|
return "Frontend Error", 404
|
2026-01-09 12:48:50 +08:00
|
|
|
|
|
|
|
|
|
|
@app.route('/<path:path>')
|
|
|
|
|
|
def serve_static(path):
|
2026-02-08 10:53:00 +08:00
|
|
|
|
if path.startswith('api'):
|
|
|
|
|
|
return jsonify({'code': 404, 'message': 'API endpoint not found'}), 404
|
|
|
|
|
|
|
2026-01-09 12:48:50 +08:00
|
|
|
|
file_path = os.path.join(app.static_folder, path)
|
|
|
|
|
|
if os.path.exists(file_path):
|
|
|
|
|
|
return send_from_directory(app.static_folder, path)
|
2026-02-06 10:08:49 +08:00
|
|
|
|
|
2026-02-08 10:53:00 +08:00
|
|
|
|
return send_from_directory(app.static_folder, 'index.html')
|
2026-01-09 12:48:50 +08:00
|
|
|
|
|
|
|
|
|
|
with app.app_context():
|
|
|
|
|
|
db.create_all()
|
|
|
|
|
|
|
|
|
|
|
|
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
debug_mode = not getattr(sys, 'frozen', False)
|
2026-02-03 17:15:42 +08:00
|
|
|
|
|
2026-02-06 10:08:49 +08:00
|
|
|
|
print(f"🚀 服务启动中... 数据库: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
2026-02-08 10:53:00 +08:00
|
|
|
|
# 注意:use_reloader=False 防止调度器在 Debug 模式下运行两次
|
2026-01-09 12:48:50 +08:00
|
|
|
|
app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False)
|