""" news_report / news_event 只读查询层(日报结构化入库,见 docs/db_schema.md)。 连接配置来自环境变量(与 docs/report_db_design.md §7 保持一致): NEWS_DB_HOST / NEWS_DB_PORT / NEWS_DB_USER / NEWS_DB_PASSWORD / NEWS_DB_NAME NEWS_DB_PASSWORD 缺失时直接报错,禁止默认密码。 所有 SQL 均为 MariaDB 方言、参数化查询(防 SQL 注入),不依赖 ORM。 """ import json import os from datetime import date, datetime, timedelta import mysql.connector _REPORT_FIELDS = ( "id, report_date, report_type, file_name, generated_at, " "ai_summary, stats, created_at" ) _EVENT_FIELDS = ( "id, report_id, section, rank, importance, event_type, title, " "summary, sentiment, source, url" ) def load_db_config() -> dict: """从环境变量读取 NEWS_DB_* 连接配置,密码缺失时抛错。""" password = os.getenv("NEWS_DB_PASSWORD") if not password: raise RuntimeError( "NEWS_DB_PASSWORD 未设置,禁止使用默认密码连接 news 库" ) return { "host": os.getenv("NEWS_DB_HOST", "127.0.0.1"), "port": int(os.getenv("NEWS_DB_PORT", "3306")), "user": os.getenv("NEWS_DB_USER", "myquant"), "password": password, "database": os.getenv("NEWS_DB_NAME", "myquant"), "charset": "utf8mb4", } def _connect(): return mysql.connector.connect(**load_db_config()) def _row_to_dict(row: dict) -> dict: """序列化行:stats JSON 解析、日期/时间转 ISO 字符串。""" d = dict(row) if d.get("stats") is not None: try: d["stats"] = json.loads(d["stats"]) except (TypeError, ValueError): d["stats"] = None for k, v in d.items(): if isinstance(v, (date, datetime)): d[k] = v.isoformat() return d def fetch_reports( report_type: str | None = None, start_date: date | None = None, end_date: date | None = None, report_id: int | None = None, ): """ 日报查询。 report_id 给定 → 返回单份详情 dict(含 events,按 section, rank 排序); 不存在返回 None。 否则 → 返回列表:每天每类型取最新一份(MAX(generated_at) 子查询), 仅主表字段(轻量,不带 events)。 """ conn = _connect() try: cur = conn.cursor(dictionary=True) if report_id is not None: cur.execute( f"SELECT {_REPORT_FIELDS} FROM news_report WHERE id = %s", (report_id,), ) row = cur.fetchone() if row is None: return None report = _row_to_dict(row) cur.execute( "SELECT id, section, rank, importance, event_type, title, " "summary, sentiment, source, url " "FROM news_event WHERE report_id = %s ORDER BY section, rank", (report_id,), ) report["events"] = [dict(r) for r in cur.fetchall()] return report where, params = [], [] if report_type: where.append("r.report_type = %s") params.append(report_type) if start_date: where.append("r.report_date >= %s") params.append(start_date.isoformat()) if end_date: where.append("r.report_date <= %s") params.append(end_date.isoformat()) cond = (" WHERE " + " AND ".join(where)) if where else "" sql = ( "SELECT r.id, r.report_date, r.report_type, r.file_name, " "r.generated_at, r.ai_summary, r.stats, r.created_at " "FROM news_report r " "JOIN (" " SELECT report_date, report_type, MAX(generated_at) AS g " " FROM news_report GROUP BY report_date, report_type" ") t ON r.report_date = t.report_date " " AND r.report_type = t.report_type " " AND r.generated_at = t.g" + cond + " ORDER BY r.report_date DESC, r.report_type" ) cur.execute(sql, tuple(params)) return [_row_to_dict(r) for r in cur.fetchall()] finally: conn.close() def fetch_important_events( days: int = 7, importance: int = 4, report_type: str | None = None, section: str | None = None, limit: int = 100, ) -> list: """ 跨日报重要事件聚合检索(最近 N 天,importance >= 阈值)。 按 importance DESC, report_date DESC 排序。 """ conn = _connect() try: cur = conn.cursor(dictionary=True) since = (date.today() - timedelta(days=days)).isoformat() where = ["r.report_date >= %s", "e.importance >= %s"] params = [since, int(importance)] if report_type: where.append("r.report_type = %s") params.append(report_type) if section: where.append("e.section = %s") params.append(section) sql = ( "SELECT r.report_date, r.report_type, e.id, e.section, e.rank, " "e.importance, e.event_type, e.title, e.summary, e.sentiment, " "e.source, e.url " "FROM news_event e " "JOIN news_report r ON r.id = e.report_id " "WHERE " + " AND ".join(where) + " ORDER BY e.importance DESC, r.report_date DESC, " "e.section, e.rank " + "LIMIT %s" ) params.append(int(limit)) cur.execute(sql, tuple(params)) return [dict(r) for r in cur.fetchall()] finally: conn.close()