| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207 |
- from __future__ import annotations
- import csv
- import re
- import sqlite3
- from datetime import date, timedelta
- from pathlib import Path
- from app.config import PROJECT_ROOT
- from app.schemas import Evidence
- SCHEMA_SQL = """
- CREATE TABLE IF NOT EXISTS orders (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- ordered_at TEXT NOT NULL,
- order_id TEXT NOT NULL UNIQUE,
- user_id TEXT NOT NULL,
- product_name TEXT NOT NULL,
- category TEXT NOT NULL,
- amount REAL NOT NULL,
- status TEXT NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_orders_user_time
- ON orders(user_id, ordered_at);
- CREATE INDEX IF NOT EXISTS idx_orders_product
- ON orders(product_name);
- """
- DEFAULT_SOURCE_PATH = PROJECT_ROOT / "data" / "source" / "orders.csv"
- ORDER_ID_PATTERN = re.compile(r"^O\d{7}$")
- USER_ID_PATTERN = re.compile(r"^U\d{4}$")
- ALLOWED_STATUSES = {"paid", "shipped", "completed", "cancelled", "refunded"}
- ACTIVE_STATUSES = ("paid", "shipped", "completed")
- def load_orders(source_path: Path) -> list[tuple[date, str, str, str, str, float, str]]:
- if not source_path.exists():
- raise FileNotFoundError(f"SQLite 原始订单不存在:{source_path}")
- # 相对天数在导入时转换,保证“最近 30 天”样例不会随课程日期过期。
- today = date.today()
- rows: list[tuple[date, str, str, str, str, float, str]] = []
- with source_path.open("r", encoding="utf-8-sig", newline="") as file:
- reader = csv.DictReader(file)
- required_fields = {
- "days_ago",
- "order_id",
- "user_id",
- "product_name",
- "category",
- "amount",
- "status",
- }
- if not reader.fieldnames or not required_fields <= set(reader.fieldnames):
- raise ValueError("orders.csv 缺少必需字段")
- for line_number, item in enumerate(reader, start=2):
- try:
- days_ago = int(item["days_ago"])
- amount = float(item["amount"])
- except (TypeError, ValueError) as exc:
- raise ValueError(f"第 {line_number} 行数值字段非法") from exc
- if not 0 <= days_ago <= 3650:
- raise ValueError(f"第 {line_number} 行 days_ago 超出范围")
- if amount < 0:
- raise ValueError(f"第 {line_number} 行 amount 不能为负数")
- order_id = item["order_id"].strip().upper()
- user_id = item["user_id"].strip().upper()
- product_name = item["product_name"].strip()
- category = item["category"].strip().lower()
- status = item["status"].strip().lower()
- if not ORDER_ID_PATTERN.fullmatch(order_id):
- raise ValueError(f"第 {line_number} 行 order_id 非法")
- if not USER_ID_PATTERN.fullmatch(user_id):
- raise ValueError(f"第 {line_number} 行 user_id 非法")
- if not product_name or not category:
- raise ValueError(f"第 {line_number} 行商品信息为空")
- if status not in ALLOWED_STATUSES:
- raise ValueError(f"第 {line_number} 行 status 非法")
- rows.append(
- (
- today - timedelta(days=days_ago),
- order_id,
- user_id,
- product_name,
- category,
- amount,
- status,
- )
- )
- if not rows:
- raise ValueError("orders.csv 没有数据")
- return rows
- def initialize_database(
- path: Path,
- source_path: Path | None = None,
- reset: bool = False,
- ) -> int:
- path.parent.mkdir(parents=True, exist_ok=True)
- if reset and path.exists():
- path.unlink()
- # CSV 是可审计真源,SQLite 只负责课程中的精确过滤和聚合。
- rows = load_orders(source_path or DEFAULT_SOURCE_PATH)
- with sqlite3.connect(path) as connection:
- connection.executescript(SCHEMA_SQL)
- current = connection.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
- if current == 0:
- connection.executemany(
- """
- INSERT INTO orders(
- ordered_at, order_id, user_id, product_name,
- category, amount, status
- )
- VALUES (?, ?, ?, ?, ?, ?, ?)
- """,
- [
- (
- item[0].isoformat(),
- item[1],
- item[2],
- item[3],
- item[4],
- item[5],
- item[6],
- )
- for item in rows
- ],
- )
- return connection.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
- class OrderRepository:
- def __init__(self, path: Path) -> None:
- self.path = path
- def summarize_recent(
- self,
- user_id: str,
- days: int = 30,
- product_keyword: str = "",
- ) -> tuple[dict, Evidence]:
- if not self.path.exists():
- raise RuntimeError(
- "业务数据库不存在,请先执行 uv run python scripts/prepare_data.py"
- )
- normalized_user_id = user_id.strip().upper()
- if not USER_ID_PATTERN.fullmatch(normalized_user_id):
- raise ValueError("user_id 必须是 U 加四位数字")
- if not 1 <= days <= 365:
- raise ValueError("days 必须在 1 到 365 之间")
- if len(product_keyword) > 100:
- raise ValueError("product_keyword 过长")
- start_date = (date.today() - timedelta(days=days)).isoformat()
- normalized_product = product_keyword.strip()
- # SQL 模板固定并使用占位符;模型不能生成 SQL 或拼接 WHERE 表达式。
- sql = """
- SELECT COUNT(*) AS order_count,
- COALESCE(SUM(amount), 0) AS total_paid,
- SUM(CASE WHEN amount < 99 THEN 1 ELSE 0 END) AS below_99_count,
- GROUP_CONCAT(order_id) AS order_ids
- FROM orders
- WHERE user_id = ?
- AND ordered_at >= ?
- AND status IN (?, ?, ?)
- AND (? = '' OR product_name LIKE ?)
- """
- product_pattern = f"%{normalized_product}%" if normalized_product else ""
- parameters = (
- normalized_user_id,
- start_date,
- *ACTIVE_STATUSES,
- normalized_product,
- product_pattern,
- )
- with sqlite3.connect(self.path) as connection:
- connection.row_factory = sqlite3.Row
- row = connection.execute(sql, parameters).fetchone()
- result = {
- "user_id": normalized_user_id,
- "days": days,
- "product_keyword": normalized_product,
- "order_count": int(row["order_count"]),
- "total_paid": round(float(row["total_paid"]), 2),
- "below_99_count": int(row["below_99_count"] or 0),
- "order_ids": row["order_ids"].split(",") if row["order_ids"] else [],
- }
- product_scope = f"、商品包含“{normalized_product}”" if normalized_product else ""
- evidence = Evidence(
- source_type="sql",
- source="orders",
- content=(
- f"用户 {normalized_user_id} 最近 {days} 天{product_scope}的有效订单共 "
- f"{result['order_count']} 单,实付总额 {result['total_paid']:.2f} 元,"
- f"其中低于 99 元 {result['below_99_count']} 单。"
- ),
- metadata={"query_name": "summarize_recent_orders", **result},
- )
- return result, evidence
|