""" db 服务的内置日志子模块。 提供两部分能力: 1. log_internal() — db 进程内部直接写 Logs 表(无 HTTP 往返,无循环依赖) 2. /log/* HTTP 路由 — 供外部服务推送 / 查询 / 统计 / 清理日志 设计要点(防递归): - log_internal() 直接操作数据库,不经过任何 HTTP 路由 - /log/ingest 成功后不再调 log_internal - /log/ingest 失败时才调 log_internal,而 log_internal 不会回调 /log/ingest """ import json import threading import time from datetime import datetime, timezone, timedelta from flask import Blueprint, request, jsonify from sqlalchemy import ( Table, insert, select, delete, func, and_, or_, MetaData ) bp = Blueprint('log', __name__, url_prefix='/log') # ── 常量 ────────────────────────────────────────────────────────────── VALID_SERVICES = { 'waf', 'gateway', 'static', 'dnfs', 'db', 'ai_review', 'captcha', 'core', } VALID_LEVELS = {'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'} MAX_BATCH = 100 # 单次 /log/ingest 最多 100 条 MAX_MESSAGE_LEN = 8192 # message 上限 8KB MAX_EXTRA_LEN = 4096 # extra 上限 4KB DEFAULT_RETENTION_DAYS = 30 DEFAULT_MAX_RECORDS = 1_000_000 DEFAULT_CLEANUP_INTERVAL = 3600 # 秒 # ── 表管理 ───────────────────────────────────────────────────────────── _LOGS_TABLE_CACHE = {} def _ensure_logs_table(metadata: MetaData, engine): """确保 Logs 表已建好并已反射到 metadata(首次调用时建表 + 反射)。""" if 'Logs' in _LOGS_TABLE_CACHE: return _LOGS_TABLE_CACHE['Logs'] # 先用原生 SQL 建表(SQLAlchemy 反射要求表已存在) with engine.connect() as conn: conn.exec_driver_sql(""" CREATE TABLE IF NOT EXISTS Logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, service TEXT NOT NULL, level TEXT NOT NULL, message TEXT NOT NULL, trace_id TEXT, extra TEXT ) """) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)" ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)" ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)" ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)" ) conn.commit() # 反射到 metadata table = Table('Logs', metadata, autoload_with=engine) _LOGS_TABLE_CACHE['Logs'] = table return table # ── 内部日志记录(db 自己用,无 HTTP)───────────────────────────────── def log_internal(engine, metadata, service, level, message, trace_id=None, extra=None): """ db 进程内部记录日志 — 直接操作数据库,不走 HTTP,无循环依赖。 供 db 的 /execute 路由内部调用。失败时静默吞掉,不影响主业务。 """ try: table = _ensure_logs_table(metadata, engine) extra_json = json.dumps(extra, ensure_ascii=False) if extra else None if len(message) > MAX_MESSAGE_LEN: message = message[:MAX_MESSAGE_LEN] if extra_json and len(extra_json) > MAX_EXTRA_LEN: extra_json = extra_json[:MAX_EXTRA_LEN] with engine.connect() as conn: conn.execute(insert(table).values( ts=datetime.now(timezone.utc).isoformat(), service=service, level=level, message=message, trace_id=trace_id, extra=extra_json, )) conn.commit() except Exception: # 日志记录失败不应影响主业务 pass # ── HTTP 路由 ────────────────────────────────────────────────────────── def init_log_routes(app, engine, metadata, retention_days=DEFAULT_RETENTION_DAYS, max_records=DEFAULT_MAX_RECORDS, cleanup_interval=DEFAULT_CLEANUP_INTERVAL): """把日志蓝图注册到 Flask app,并注入 engine/metadata。""" app.config['LOG_ENGINE'] = engine app.config['LOG_METADATA'] = metadata app.config['LOG_RETENTION_DAYS'] = retention_days app.config['LOG_MAX_RECORDS'] = max_records app.config['LOG_CLEANUP_INTERVAL'] = cleanup_interval # 建表 + 反射 count = 0 try: table = _ensure_logs_table(metadata, engine) with engine.connect() as conn: count = conn.execute( select(func.count()).select_from(table) ).scalar() or 0 except Exception as e: log_internal(engine, metadata, 'db', 'ERROR', f'Logs 表初始化失败: {e}') # 注册路由 app.register_blueprint(bp) # 启动后台清理线程 _start_cleanup_thread(engine, metadata, retention_days, max_records, cleanup_interval) # ── 5.1 推送日志 ────────────────────────────────────────────────────── @bp.route('/ingest', methods=['POST']) def ingest(): """接收外部日志推送(单条或批量)。""" data = request.get_json(silent=True) if not data or 'logs' not in data: return jsonify({'success': False, 'error': '请求体缺少 logs 字段'}), 400 logs = data['logs'] if not isinstance(logs, list) or not logs: return jsonify({'success': False, 'error': 'logs 必须是非空数组'}), 400 if len(logs) > MAX_BATCH: return jsonify({ 'success': False, 'error': f'单次最多 {MAX_BATCH} 条,当前 {len(logs)} 条' }), 400 engine = app.config['LOG_ENGINE'] metadata = app.config['LOG_METADATA'] try: table = _ensure_logs_table(metadata, engine) except Exception as e: return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500 # 校验 + 组装 now = datetime.now(timezone.utc).isoformat() rows = [] for i, log in enumerate(logs): if not isinstance(log, dict): return jsonify({'success': False, 'error': f'第 {i} 条日志不是有效对象'}), 400 service = log.get('service') level = log.get('level') message = log.get('message') if not service or service not in VALID_SERVICES: return jsonify({'success': False, 'error': f'第 {i} 条: service 无效或缺失'}), 400 if not level or level not in VALID_LEVELS: return jsonify({'success': False, 'error': f'第 {i} 条: level 无效或缺失'}), 400 if not message or not isinstance(message, str): return jsonify({'success': False, 'error': f'第 {i} 条: message 缺失或非字符串'}), 400 if len(message) > MAX_MESSAGE_LEN: message = message[:MAX_MESSAGE_LEN] trace_id = log.get('trace_id') extra = log.get('extra') extra_json = None if extra is not None: extra_json = json.dumps(extra, ensure_ascii=False) if len(extra_json) > MAX_EXTRA_LEN: extra_json = extra_json[:MAX_EXTRA_LEN] rows.append({ 'ts': log.get('ts') or now, 'service': service, 'level': level, 'message': message, 'trace_id': trace_id, 'extra': extra_json, }) # 写入(成功后不再调 log_internal,防递归) try: with engine.connect() as conn: conn.execute(insert(table), rows) conn.commit() except Exception as e: # 失败时可以安全地记日志(log_internal 不回调 ingest) log_internal(engine, metadata, 'db', 'ERROR', f'/log/ingest 写入失败: {e}') return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': True, 'data': {'inserted': len(rows)}}) # ── 5.2 查询日志 ─────────────────────────────────────────────────────── @bp.route('/query', methods=['POST']) def query(): """结构化查询日志。""" data = request.get_json(silent=True) or {} engine = app.config['LOG_ENGINE'] metadata = app.config['LOG_METADATA'] try: table = _ensure_logs_table(metadata, engine) except Exception as e: return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500 clauses = [] # service(支持单个字符串或数组) svc = data.get('service') if svc: col = table.columns['service'] if isinstance(svc, list): clauses.append(col.in_(svc)) else: clauses.append(col == svc) # level(支持单个字符串或数组) lvl = data.get('level') if lvl: col = table.columns['level'] if isinstance(lvl, list): clauses.append(col.in_(lvl)) else: clauses.append(col == lvl) # trace_id tid = data.get('trace_id') if tid: clauses.append(table.columns['trace_id'] == tid) # 时间区间 since = data.get('since') until = data.get('until') ts_col = table.columns['ts'] if since: clauses.append(ts_col >= since) if until: clauses.append(ts_col <= until) # 关键字模糊搜索 keyword = data.get('keyword') if keyword: clauses.append(table.columns['message'].like(f'%{keyword}%')) where_cond = and_(*clauses) if clauses else None # 排序 order_col_name = data.get('order_by', 'ts') if order_col_name not in ('id', 'ts', 'service', 'level'): order_col_name = 'ts' order_col = table.columns[order_col_name] desc = data.get('desc', True) order_expr = order_col.desc() if desc else order_col.asc() # 分页 limit = min(data.get('limit', 100), 1000) offset = data.get('offset', 0) try: with engine.connect() as conn: # 总数 count_stmt = select(func.count()).select_from(table) if where_cond is not None: count_stmt = count_stmt.where(where_cond) total = conn.execute(count_stmt).scalar() or 0 # 查询 stmt = select(table) if where_cond is not None: stmt = stmt.where(where_cond) stmt = stmt.order_by(order_expr).limit(limit).offset(offset) rows = conn.execute(stmt).fetchall() logs = [] for row in rows: d = dict(row._mapping) if d.get('extra'): try: d['extra'] = json.loads(d['extra']) except (json.JSONDecodeError, TypeError): pass logs.append(d) return jsonify({'success': True, 'data': {'total': total, 'logs': logs}}) except Exception as e: log_internal(engine, metadata, 'db', 'ERROR', f'/log/query 失败: {e}') return jsonify({'success': False, 'error': str(e)}), 500 # ── 5.3 统计聚合 ─────────────────────────────────────────────────────── @bp.route('/stats', methods=['POST']) def stats(): """日志分类统计。""" data = request.get_json(silent=True) or {} engine = app.config['LOG_ENGINE'] metadata = app.config['LOG_METADATA'] try: table = _ensure_logs_table(metadata, engine) except Exception as e: return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500 group_by = data.get('group_by', 'service') group_fields = [g.strip() for g in group_by.split(',') if g.strip()] for g in group_fields: if g not in ('service', 'level'): return jsonify({ 'success': False, 'error': f'group_by 仅支持 service / level,收到: {g}' }), 400 # 时间过滤 clauses = [] since = data.get('since') until = data.get('until') ts_col = table.columns['ts'] if since: clauses.append(ts_col >= since) if until: clauses.append(ts_col <= until) where_cond = and_(*clauses) if clauses else None try: with engine.connect() as conn: cols = [table.columns[g] for g in group_fields] + [func.count().label('count')] stmt = select(*cols) if where_cond is not None: stmt = stmt.where(where_cond) stmt = stmt.group_by(*[table.columns[g] for g in group_fields]) rows = conn.execute(stmt).fetchall() groups = [dict(row._mapping) for row in rows] # 总数 count_stmt = select(func.count()).select_from(table) if where_cond is not None: count_stmt = count_stmt.where(where_cond) total = conn.execute(count_stmt).scalar() or 0 return jsonify({'success': True, 'data': {'groups': groups, 'total': total}}) except Exception as e: log_internal(engine, metadata, 'db', 'ERROR', f'/log/stats 失败: {e}') return jsonify({'success': False, 'error': str(e)}), 500 # ── 5.4 清理过期 ────────────────────────────────────────────────────── @bp.route('/purge', methods=['POST']) def purge(): """清理过期日志。""" data = request.get_json(silent=True) or {} before = data.get('before') if not before: return jsonify({'success': False, 'error': '缺少 before 字段'}), 400 engine = app.config['LOG_ENGINE'] metadata = app.config['LOG_METADATA'] try: table = _ensure_logs_table(metadata, engine) except Exception as e: return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500 try: with engine.connect() as conn: stmt = delete(table).where(table.columns['ts'] < before) result = conn.execute(stmt) conn.commit() deleted = result.rowcount log_internal(engine, metadata, 'db', 'INFO', f'/log/purge 清理 {deleted} 条 (before={before})') return jsonify({'success': True, 'data': {'deleted': deleted}}) except Exception as e: log_internal(engine, metadata, 'db', 'ERROR', f'/log/purge 失败: {e}') return jsonify({'success': False, 'error': str(e)}), 500 # ── 5.5 健康检查 ────────────────────────────────────────────────────── @bp.route('/health', methods=['GET']) def health(): """日志模块健康检查。""" engine = app.config['LOG_ENGINE'] metadata = app.config['LOG_METADATA'] try: table = _ensure_logs_table(metadata, engine) with engine.connect() as conn: count = conn.execute(select(func.count()).select_from(table)).scalar() or 0 return jsonify({'success': True, 'data': {'status': 'ok', 'count': count}}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 # ── 后台自动清理线程 ────────────────────────────────────────────────── def _start_cleanup_thread(engine, metadata, retention_days=DEFAULT_RETENTION_DAYS, max_records=DEFAULT_MAX_RECORDS, interval=DEFAULT_CLEANUP_INTERVAL): """启动后台守护线程,定期清理过期日志。""" def _cleanup_loop(): while True: time.sleep(interval) try: _do_cleanup(engine, metadata, retention_days, max_records) except Exception: # 清理失败不影响服务运行 pass t = threading.Thread(target=_cleanup_loop, daemon=True, name='logs-cleanup') t.start() def _do_cleanup(engine, metadata, retention_days, max_records): """执行一次清理:删过期 + 裁剪超量。""" table = _ensure_logs_table(metadata, engine) # 1. 删除过期日志 cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).isoformat() with engine.connect() as conn: result = conn.execute(delete(table).where(table.columns['ts'] < cutoff)) conn.commit() expired_deleted = result.rowcount # 2. 裁剪超量记录(保留最新的 max_records 条) count = conn.execute(select(func.count()).select_from(table)).scalar() or 0 trimmed = 0 if count > max_records: excess = count - max_records # 找到第 max_records 条的 ts(按时间正序的第 max_records 条) cutoff_row = conn.execute( select(table.columns['ts']) .order_by(table.columns['ts'].asc()) .limit(1).offset(max_records) ).fetchone() if cutoff_row: trim_result = conn.execute( delete(table).where(table.columns['ts'] < cutoff_row[0]) ) conn.commit() trimmed = trim_result.rowcount if expired_deleted or trimmed: log_internal(engine, metadata, 'db', 'INFO', f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')