sql_store.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. from __future__ import annotations
  2. import csv
  3. import re
  4. import sqlite3
  5. from datetime import date, timedelta
  6. from pathlib import Path
  7. from app.config import PROJECT_ROOT
  8. from app.schemas import Evidence
  9. SCHEMA_SQL = """
  10. CREATE TABLE IF NOT EXISTS orders (
  11. id INTEGER PRIMARY KEY AUTOINCREMENT,
  12. ordered_at TEXT NOT NULL,
  13. order_id TEXT NOT NULL UNIQUE,
  14. user_id TEXT NOT NULL,
  15. product_name TEXT NOT NULL,
  16. category TEXT NOT NULL,
  17. amount REAL NOT NULL,
  18. status TEXT NOT NULL
  19. );
  20. CREATE INDEX IF NOT EXISTS idx_orders_user_time
  21. ON orders(user_id, ordered_at);
  22. CREATE INDEX IF NOT EXISTS idx_orders_product
  23. ON orders(product_name);
  24. """
  25. DEFAULT_SOURCE_PATH = PROJECT_ROOT / "data" / "source" / "orders.csv"
  26. ORDER_ID_PATTERN = re.compile(r"^O\d{7}$")
  27. USER_ID_PATTERN = re.compile(r"^U\d{4}$")
  28. ALLOWED_STATUSES = {"paid", "shipped", "completed", "cancelled", "refunded"}
  29. ACTIVE_STATUSES = ("paid", "shipped", "completed")
  30. def load_orders(source_path: Path) -> list[tuple[date, str, str, str, str, float, str]]:
  31. if not source_path.exists():
  32. raise FileNotFoundError(f"SQLite 原始订单不存在:{source_path}")
  33. # 相对天数在导入时转换,保证“最近 30 天”样例不会随课程日期过期。
  34. today = date.today()
  35. rows: list[tuple[date, str, str, str, str, float, str]] = []
  36. with source_path.open("r", encoding="utf-8-sig", newline="") as file:
  37. reader = csv.DictReader(file)
  38. required_fields = {
  39. "days_ago",
  40. "order_id",
  41. "user_id",
  42. "product_name",
  43. "category",
  44. "amount",
  45. "status",
  46. }
  47. if not reader.fieldnames or not required_fields <= set(reader.fieldnames):
  48. raise ValueError("orders.csv 缺少必需字段")
  49. for line_number, item in enumerate(reader, start=2):
  50. try:
  51. days_ago = int(item["days_ago"])
  52. amount = float(item["amount"])
  53. except (TypeError, ValueError) as exc:
  54. raise ValueError(f"第 {line_number} 行数值字段非法") from exc
  55. if not 0 <= days_ago <= 3650:
  56. raise ValueError(f"第 {line_number} 行 days_ago 超出范围")
  57. if amount < 0:
  58. raise ValueError(f"第 {line_number} 行 amount 不能为负数")
  59. order_id = item["order_id"].strip().upper()
  60. user_id = item["user_id"].strip().upper()
  61. product_name = item["product_name"].strip()
  62. category = item["category"].strip().lower()
  63. status = item["status"].strip().lower()
  64. if not ORDER_ID_PATTERN.fullmatch(order_id):
  65. raise ValueError(f"第 {line_number} 行 order_id 非法")
  66. if not USER_ID_PATTERN.fullmatch(user_id):
  67. raise ValueError(f"第 {line_number} 行 user_id 非法")
  68. if not product_name or not category:
  69. raise ValueError(f"第 {line_number} 行商品信息为空")
  70. if status not in ALLOWED_STATUSES:
  71. raise ValueError(f"第 {line_number} 行 status 非法")
  72. rows.append(
  73. (
  74. today - timedelta(days=days_ago),
  75. order_id,
  76. user_id,
  77. product_name,
  78. category,
  79. amount,
  80. status,
  81. )
  82. )
  83. if not rows:
  84. raise ValueError("orders.csv 没有数据")
  85. return rows
  86. def initialize_database(
  87. path: Path,
  88. source_path: Path | None = None,
  89. reset: bool = False,
  90. ) -> int:
  91. path.parent.mkdir(parents=True, exist_ok=True)
  92. if reset and path.exists():
  93. path.unlink()
  94. # CSV 是可审计真源,SQLite 只负责课程中的精确过滤和聚合。
  95. rows = load_orders(source_path or DEFAULT_SOURCE_PATH)
  96. with sqlite3.connect(path) as connection:
  97. connection.executescript(SCHEMA_SQL)
  98. current = connection.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
  99. if current == 0:
  100. connection.executemany(
  101. """
  102. INSERT INTO orders(
  103. ordered_at, order_id, user_id, product_name,
  104. category, amount, status
  105. )
  106. VALUES (?, ?, ?, ?, ?, ?, ?)
  107. """,
  108. [
  109. (
  110. item[0].isoformat(),
  111. item[1],
  112. item[2],
  113. item[3],
  114. item[4],
  115. item[5],
  116. item[6],
  117. )
  118. for item in rows
  119. ],
  120. )
  121. return connection.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
  122. class OrderRepository:
  123. def __init__(self, path: Path) -> None:
  124. self.path = path
  125. def summarize_recent(
  126. self,
  127. user_id: str,
  128. days: int = 30,
  129. product_keyword: str = "",
  130. ) -> tuple[dict, Evidence]:
  131. if not self.path.exists():
  132. raise RuntimeError(
  133. "业务数据库不存在,请先执行 uv run python scripts/prepare_data.py"
  134. )
  135. normalized_user_id = user_id.strip().upper()
  136. if not USER_ID_PATTERN.fullmatch(normalized_user_id):
  137. raise ValueError("user_id 必须是 U 加四位数字")
  138. if not 1 <= days <= 365:
  139. raise ValueError("days 必须在 1 到 365 之间")
  140. if len(product_keyword) > 100:
  141. raise ValueError("product_keyword 过长")
  142. start_date = (date.today() - timedelta(days=days)).isoformat()
  143. normalized_product = product_keyword.strip()
  144. # SQL 模板固定并使用占位符;模型不能生成 SQL 或拼接 WHERE 表达式。
  145. sql = """
  146. SELECT COUNT(*) AS order_count,
  147. COALESCE(SUM(amount), 0) AS total_paid,
  148. SUM(CASE WHEN amount < 99 THEN 1 ELSE 0 END) AS below_99_count,
  149. GROUP_CONCAT(order_id) AS order_ids
  150. FROM orders
  151. WHERE user_id = ?
  152. AND ordered_at >= ?
  153. AND status IN (?, ?, ?)
  154. AND (? = '' OR product_name LIKE ?)
  155. """
  156. product_pattern = f"%{normalized_product}%" if normalized_product else ""
  157. parameters = (
  158. normalized_user_id,
  159. start_date,
  160. *ACTIVE_STATUSES,
  161. normalized_product,
  162. product_pattern,
  163. )
  164. with sqlite3.connect(self.path) as connection:
  165. connection.row_factory = sqlite3.Row
  166. row = connection.execute(sql, parameters).fetchone()
  167. result = {
  168. "user_id": normalized_user_id,
  169. "days": days,
  170. "product_keyword": normalized_product,
  171. "order_count": int(row["order_count"]),
  172. "total_paid": round(float(row["total_paid"]), 2),
  173. "below_99_count": int(row["below_99_count"] or 0),
  174. "order_ids": row["order_ids"].split(",") if row["order_ids"] else [],
  175. }
  176. product_scope = f"、商品包含“{normalized_product}”" if normalized_product else ""
  177. evidence = Evidence(
  178. source_type="sql",
  179. source="orders",
  180. content=(
  181. f"用户 {normalized_user_id} 最近 {days} 天{product_scope}的有效订单共 "
  182. f"{result['order_count']} 单,实付总额 {result['total_paid']:.2f} 元,"
  183. f"其中低于 99 元 {result['below_99_count']} 单。"
  184. ),
  185. metadata={"query_name": "summarize_recent_orders", **result},
  186. )
  187. return result, evidence