- api/report/ 包:query(连库+SQL)/ views(2 视图)/ serializers(OpenAPI)/ tests(17 单测) - urls.py 注册 news/reports/、news/events/;settings.py SPECTACULAR 加「日报」tag - .env.example 补 NEWS_DB_* 占位配置;README/continuation.md 更新 - docs/news_report_api.md 使用手册;CLAUDE*.md 修正 CLI 路径为 finance/ 前缀
169 lines
5.5 KiB
Python
169 lines
5.5 KiB
Python
"""
|
|
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()
|