db.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. # -*- coding: utf-8 -*-
  2. """
  3. SQLite 存储层
  4. - stores 门店主数据
  5. - metrics 指标明细(按 门店 × 周期 × 日期 × 粒度 唯一)
  6. - batches 导入批次
  7. - thresholds 考核指标阈值(单行 JSON)
  8. - settings 杂项键值
  9. 日粒度数据按月聚合,聚合口径统一在这里,前端不重复算。
  10. """
  11. import json
  12. import os
  13. import re
  14. import sqlite3
  15. from datetime import datetime
  16. import rules
  17. BASE = os.path.dirname(os.path.abspath(__file__))
  18. DATA_DIR = os.path.join(os.path.dirname(BASE), "data")
  19. DB_PATH = os.path.join(DATA_DIR, "app.db")
  20. INBOX = os.path.join(DATA_DIR, "inbox")
  21. PROCESSED = os.path.join(DATA_DIR, "processed")
  22. SCHEMA = """
  23. CREATE TABLE IF NOT EXISTS stores (
  24. id TEXT PRIMARY KEY,
  25. name TEXT NOT NULL,
  26. city TEXT,
  27. status TEXT DEFAULT 'active',
  28. first_seen TEXT,
  29. last_seen TEXT
  30. );
  31. CREATE TABLE IF NOT EXISTS metrics (
  32. store_id TEXT NOT NULL,
  33. period TEXT NOT NULL, -- YYYY-MM(月)或 截至日 YYYY-MM-DD(快照)
  34. date TEXT NOT NULL DEFAULT '',-- YYYY-MM-DD;月汇总行留空
  35. granularity TEXT NOT NULL, -- daily | period | snapshot
  36. window_start TEXT DEFAULT '', -- 快照:统计窗口开始日
  37. window_end TEXT DEFAULT '', -- 快照:统计窗口结束日(= 数据截至日)
  38. visit INTEGER DEFAULT 0,
  39. deal INTEGER DEFAULT 0,
  40. deal_refund INTEGER DEFAULT 0,
  41. intend_deal INTEGER DEFAULT 0,
  42. used_amount REAL DEFAULT 0,
  43. used_coupons INTEGER DEFAULT 0,
  44. intend_refund INTEGER DEFAULT 0,
  45. videos INTEGER DEFAULT 0,
  46. video_views INTEGER DEFAULT 0,
  47. score REAL,
  48. reviews_new INTEGER DEFAULT 0,
  49. good_new INTEGER DEFAULT 0,
  50. bad_new INTEGER DEFAULT 0,
  51. reviews_total INTEGER DEFAULT 0,
  52. good_total INTEGER DEFAULT 0,
  53. bad_total INTEGER DEFAULT 0,
  54. reply_rate REAL,
  55. complaint_rate REAL,
  56. return_rate REAL,
  57. lit INTEGER DEFAULT 0,
  58. lit_posts INTEGER DEFAULT 0,
  59. favs INTEGER DEFAULT 0,
  60. rank_note TEXT,
  61. batch_id INTEGER,
  62. PRIMARY KEY (store_id, period, date, granularity)
  63. );
  64. CREATE INDEX IF NOT EXISTS idx_metrics_period ON metrics(period, granularity);
  65. CREATE TABLE IF NOT EXISTS batches (
  66. id INTEGER PRIMARY KEY AUTOINCREMENT,
  67. filename TEXT,
  68. rows_read INTEGER,
  69. rows_written INTEGER,
  70. new_stores INTEGER,
  71. period TEXT,
  72. date_from TEXT,
  73. date_to TEXT,
  74. created_at TEXT,
  75. note TEXT,
  76. file_path TEXT -- 原始文件在 data/processed 下的文件名
  77. );
  78. CREATE TABLE IF NOT EXISTS users (
  79. id INTEGER PRIMARY KEY AUTOINCREMENT,
  80. username TEXT UNIQUE NOT NULL,
  81. pass_hash TEXT NOT NULL,
  82. salt TEXT NOT NULL,
  83. role TEXT NOT NULL DEFAULT 'viewer',
  84. display_name TEXT DEFAULT '',
  85. active INTEGER DEFAULT 1,
  86. must_change INTEGER DEFAULT 0,
  87. created_at TEXT,
  88. last_login TEXT
  89. );
  90. CREATE TABLE IF NOT EXISTS sessions (
  91. token_hash TEXT PRIMARY KEY,
  92. user_id INTEGER NOT NULL,
  93. created_at TEXT,
  94. expires_at TEXT,
  95. ip TEXT DEFAULT ''
  96. );
  97. CREATE TABLE IF NOT EXISTS thresholds (
  98. id INTEGER PRIMARY KEY CHECK (id = 1),
  99. data TEXT NOT NULL,
  100. updated_at TEXT
  101. );
  102. CREATE TABLE IF NOT EXISTS settings (
  103. key TEXT PRIMARY KEY,
  104. value TEXT
  105. );
  106. """
  107. METRIC_FIELDS = ["visit", "deal", "deal_refund", "intend_deal", "used_amount",
  108. "used_coupons", "intend_refund", "videos", "video_views", "score",
  109. "reviews_new", "good_new", "bad_new", "reviews_total", "good_total",
  110. "bad_total", "reply_rate", "complaint_rate", "return_rate",
  111. "lit", "lit_posts", "favs", "rank_note"]
  112. def connect():
  113. os.makedirs(DATA_DIR, exist_ok=True)
  114. os.makedirs(INBOX, exist_ok=True)
  115. os.makedirs(PROCESSED, exist_ok=True)
  116. conn = sqlite3.connect(DB_PATH, timeout=15)
  117. conn.row_factory = sqlite3.Row
  118. conn.execute("PRAGMA journal_mode=WAL")
  119. return conn
  120. def _migrate(conn):
  121. """老库补齐新增列(SQLite 的 ADD COLUMN 不会报错重复)"""
  122. cols = {r[1] for r in conn.execute("PRAGMA table_info(metrics)")}
  123. for c in ("window_start", "window_end"):
  124. if c not in cols:
  125. conn.execute("ALTER TABLE metrics ADD COLUMN %s TEXT DEFAULT ''" % c)
  126. bcols = {r[1] for r in conn.execute("PRAGMA table_info(batches)")}
  127. if "file_path" not in bcols:
  128. conn.execute("ALTER TABLE batches ADD COLUMN file_path TEXT")
  129. def backfill_batch_files(conn):
  130. """老批次补上「原始文件」路径:用 created_at 的时间戳去匹配 processed 里的文件名前缀"""
  131. try:
  132. rows = conn.execute("SELECT id, created_at FROM batches "
  133. "WHERE file_path IS NULL OR file_path=''").fetchall()
  134. if not rows or not os.path.isdir(PROCESSED):
  135. return
  136. idx = {}
  137. for f in os.listdir(PROCESSED):
  138. m = re.match(r"^(\d{8}_\d{6})_", f)
  139. if m:
  140. idx[m.group(1)] = f
  141. for r in rows:
  142. try:
  143. stamp = datetime.fromisoformat(r["created_at"]).strftime("%Y%m%d_%H%M%S")
  144. except (TypeError, ValueError):
  145. continue
  146. f = idx.get(stamp)
  147. if f:
  148. conn.execute("UPDATE batches SET file_path=? WHERE id=?", (f, r["id"]))
  149. except Exception:
  150. pass
  151. def init_db():
  152. conn = connect()
  153. with conn:
  154. conn.executescript(SCHEMA)
  155. _migrate(conn)
  156. backfill_batch_files(conn)
  157. cur = conn.execute("SELECT id FROM thresholds WHERE id=1")
  158. if not cur.fetchone():
  159. conn.execute("INSERT INTO thresholds(id, data, updated_at) VALUES (1, ?, ?)",
  160. (json.dumps(rules.DEFAULT_THRESHOLDS, ensure_ascii=False),
  161. datetime.now().isoformat(timespec="seconds")))
  162. conn.close()
  163. def get_thresholds():
  164. """读取阈值;库里没有的新键(v2 新增门槛)用默认值补全,老库不用迁移"""
  165. conn = connect()
  166. row = conn.execute("SELECT data FROM thresholds WHERE id=1").fetchone()
  167. conn.close()
  168. T = dict(rules.DEFAULT_THRESHOLDS)
  169. if row:
  170. try:
  171. T.update(json.loads(row["data"]))
  172. except Exception:
  173. pass
  174. return T
  175. def set_thresholds(T):
  176. conn = connect()
  177. with conn:
  178. conn.execute("UPDATE thresholds SET data=?, updated_at=? WHERE id=1",
  179. (json.dumps(T, ensure_ascii=False),
  180. datetime.now().isoformat(timespec="seconds")))
  181. conn.close()
  182. def reset_thresholds():
  183. set_thresholds(dict(rules.DEFAULT_THRESHOLDS))
  184. return dict(rules.DEFAULT_THRESHOLDS)
  185. def get_setting(key, default=None):
  186. conn = connect()
  187. row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
  188. conn.close()
  189. return row["value"] if row else default
  190. def set_setting(key, value):
  191. conn = connect()
  192. with conn:
  193. conn.execute("INSERT INTO settings(key, value) VALUES(?,?) "
  194. "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, str(value)))
  195. conn.close()
  196. # ---- 文案模板(settings 表单行 JSON,key='texts')----
  197. def get_texts():
  198. """
  199. 读取文案。库里存的是整份模板,升级后旧存档会盖住新默认值,所以按规则迁移:
  200. 存档值 == v1 默认值 → 用户没改过,用新默认值
  201. 存档值 != v1 默认值 → 用户自定义过,保留
  202. """
  203. conn = connect()
  204. row = conn.execute("SELECT value FROM settings WHERE key='texts'").fetchone()
  205. conn.close()
  206. merged = dict(rules.DEFAULT_TEXTS)
  207. if row:
  208. try:
  209. stored = json.loads(row["value"])
  210. except Exception:
  211. stored = {}
  212. for k, v in stored.items():
  213. if k not in rules.DEFAULT_TEXTS:
  214. continue # 已废弃的旧键直接丢弃
  215. if rules.V1_TEXTS.get(k) == v:
  216. continue # 与 v1 默认一致 = 没自定义过
  217. merged[k] = v
  218. return merged
  219. def set_texts(X):
  220. """只存「与默认值不同」的键,默认值以后再改也能自动生效"""
  221. diff = {k: v for k, v in X.items()
  222. if k in rules.DEFAULT_TEXTS and v != rules.DEFAULT_TEXTS[k]}
  223. conn = connect()
  224. with conn:
  225. conn.execute(
  226. "INSERT INTO settings(key, value) VALUES('texts', ?) "
  227. "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
  228. (json.dumps(diff, ensure_ascii=False),))
  229. conn.close()
  230. def reset_texts():
  231. set_texts(dict(rules.DEFAULT_TEXTS))
  232. return dict(rules.DEFAULT_TEXTS)
  233. def _pick_granularity(conn, period):
  234. """
  235. 快照(近30天滚动窗口)优先级最高:一个 period 只有一份快照行/店。
  236. 同一周期两种粒度都存在时的取舍:
  237. 日数据覆盖 >= 15 天才用日粒度(避免刚导 1 天日报就把整月视图切成残缺的日聚合);
  238. 否则用月汇总行。
  239. """
  240. has_snap = conn.execute("SELECT 1 FROM metrics WHERE period=? AND granularity='snapshot' LIMIT 1",
  241. (period,)).fetchone()
  242. if has_snap:
  243. return "snapshot"
  244. has_daily = conn.execute("SELECT 1 FROM metrics WHERE period=? AND granularity='daily' LIMIT 1",
  245. (period,)).fetchone()
  246. has_month = conn.execute("SELECT 1 FROM metrics WHERE period=? AND granularity='period' LIMIT 1",
  247. (period,)).fetchone()
  248. if has_daily and has_month:
  249. n = conn.execute("SELECT COUNT(DISTINCT date) c FROM metrics WHERE period=? "
  250. "AND granularity='daily' AND date<>''", (period,)).fetchone()["c"]
  251. return "daily" if n >= 15 else "period"
  252. return "daily" if has_daily else "period"
  253. def get_periods():
  254. """
  255. 返回周期列表。三种粒度:
  256. 快照 snapshot —— 近 N 天滚动窗口,period 记「截至日」,带 window_start/end
  257. daily —— 当月按日导入
  258. period —— 月汇总
  259. """
  260. conn = connect()
  261. rows = conn.execute("""
  262. SELECT period, granularity, COUNT(DISTINCT store_id) AS stores, COUNT(DISTINCT date) AS days
  263. FROM metrics GROUP BY period, granularity
  264. """).fetchall()
  265. plans = {}
  266. for r in rows:
  267. plans.setdefault(r["period"], {})[r["granularity"]] = r
  268. out = []
  269. for p, gs in plans.items():
  270. g = _pick_granularity(conn, p)
  271. r = gs.get(g) or next(iter(gs.values()))
  272. item = {"period": p, "stores": r["stores"], "granularity": g,
  273. "days": r["days"] if g == "daily" else 0}
  274. if g == "snapshot":
  275. w = conn.execute("SELECT window_start, window_end FROM metrics "
  276. "WHERE period=? AND granularity='snapshot' LIMIT 1", (p,)).fetchone()
  277. ws, we = (w["window_start"] or "", w["window_end"] or "") if w else ("", "")
  278. d = _days_between(ws, we)
  279. item.update({"window_start": ws, "window_end": we, "as_of": we or p,
  280. "days": d or 30,
  281. "label": "近%d天 · 截至 %s" % (d or 30, (we or p)[5:])})
  282. item["sort_key"] = (item.get("as_of") or (p + "-01"))
  283. out.append(item)
  284. conn.close()
  285. return sorted(out, key=lambda x: x["sort_key"], reverse=True)
  286. def latest_period():
  287. ps = get_periods()
  288. return ps[0]["period"] if ps else None
  289. def _days_between(a, b):
  290. """两个 YYYY-MM-DD 之间的天数(含首尾)"""
  291. try:
  292. from datetime import date
  293. d1 = date.fromisoformat(a)
  294. d2 = date.fromisoformat(b)
  295. return (d2 - d1).days + 1
  296. except Exception:
  297. return 0
  298. def _weighted(items, weight_key, value_key):
  299. """加权平均:权重为 None/0 时退化为简单平均"""
  300. num = den = 0.0
  301. simple = []
  302. for it in items:
  303. v = it.get(value_key)
  304. if v is None:
  305. continue
  306. w = it.get(weight_key) or 0
  307. num += v * w
  308. den += w
  309. simple.append(v)
  310. if den > 0:
  311. return num / den
  312. return (sum(simple) / len(simple)) if simple else None
  313. def aggregate_period(period):
  314. """
  315. 按月聚合。日粒度优先;没有日粒度就用月汇总行。
  316. 返回每店一条,含派生指标 hx_rate / cp_rate / cp_src。
  317. """
  318. conn = connect()
  319. g = _pick_granularity(conn, period)
  320. rows = conn.execute("""
  321. SELECT m.*, s.name, s.city FROM metrics m JOIN stores s ON s.id = m.store_id
  322. WHERE m.period=? AND m.granularity=?
  323. ORDER BY m.store_id, m.date
  324. """, (period, g)).fetchall()
  325. conn.close()
  326. grouped = {}
  327. for r in rows:
  328. grouped.setdefault(r["store_id"], []).append(dict(r))
  329. out = []
  330. for sid, items in grouped.items():
  331. latest = max(items, key=lambda x: (x["date"] or ""))
  332. total = {k: 0 for k in ("visit", "intend_deal", "used_coupons", "used_amount",
  333. "deal", "deal_refund", "videos", "video_views", "reviews_new",
  334. "bad_new", "reviews_total", "bad_total", "favs", "lit")}
  335. for it in items:
  336. for k in total:
  337. total[k] += (it.get(k) or 0)
  338. score = None
  339. rank_note = ""
  340. for it in sorted(items, key=lambda x: x["date"] or ""):
  341. if it.get("score") is not None:
  342. score = it["score"] # 取最近一次有值(含 0 分未开分)的评分
  343. v = (it.get("rank_note") or "").strip()
  344. if v and v not in ("-", "无"):
  345. rank_note = v # 取最近一次有内容的上榜信息
  346. # 核销率分母 = 门店页成交券数(2026-11-01 用户确定口径)
  347. hx_rate = rules.diff_rate(total["used_coupons"], total["deal"])
  348. cp_rate, cp_src, _cp_small = rules.bad_rate(
  349. total["reviews_new"], total["bad_new"], total["reviews_total"], total["bad_total"])
  350. out.append({
  351. "id": sid,
  352. "name": latest["name"],
  353. "city": latest["city"] or "",
  354. "visit": total["visit"],
  355. "deal": total["deal"],
  356. "deal_refund": total["deal_refund"],
  357. "intend_deal": total["intend_deal"],
  358. "used_coupons": total["used_coupons"],
  359. "used_amount": round(total["used_amount"], 2),
  360. "score": score,
  361. "hx_rate": hx_rate,
  362. "cp_rate": cp_rate,
  363. "cp_src": cp_src,
  364. # 评价明细:差评率口径与样本量判定需要原始计数
  365. "reviews_new": total["reviews_new"],
  366. "bad_new": total["bad_new"],
  367. "reviews_total": total["reviews_total"],
  368. "bad_total": total["bad_total"],
  369. "lit": total["lit"],
  370. "rank_note": rank_note,
  371. "return_rate": _weighted(items, "deal", "return_rate"),
  372. "complaint_rate": _weighted(items, "deal", "complaint_rate"),
  373. "reply_rate": _weighted(items, "reviews_new", "reply_rate"),
  374. "videos": total["videos"],
  375. "video_views": total["video_views"],
  376. "favs": total["favs"],
  377. "days": (_days_between(items[0].get("window_start") or "",
  378. items[0].get("window_end") or "")
  379. if g == "snapshot"
  380. else len({it["date"] for it in items if it["date"]})),
  381. "window_start": items[0].get("window_start") or "",
  382. "window_end": items[0].get("window_end") or "",
  383. })
  384. return out
  385. def upsert_store(conn, sid, name, city, today):
  386. cur = conn.execute("SELECT id FROM stores WHERE id=?", (sid,))
  387. if cur.fetchone():
  388. conn.execute("UPDATE stores SET name=?, city=?, last_seen=? WHERE id=?",
  389. (name, city, today, sid))
  390. return False
  391. conn.execute("INSERT INTO stores(id, name, city, first_seen, last_seen) VALUES (?,?,?,?,?)",
  392. (sid, name, city, today, today))
  393. return True
  394. def insert_metric(conn, row, batch_id):
  395. cols = ", ".join(METRIC_FIELDS)
  396. qs = ", ".join(["?"] * len(METRIC_FIELDS))
  397. vals = [row.get(k) for k in METRIC_FIELDS]
  398. conn.execute(
  399. "INSERT OR REPLACE INTO metrics(store_id, period, date, granularity, window_start, window_end, "
  400. "%s, batch_id) VALUES (?,?,?,?,?,?,%s,?)" % (cols, qs),
  401. [row["store_id"], row["period"], row.get("date", ""), row["granularity"],
  402. row.get("window_start", ""), row.get("window_end", "")] + vals + [batch_id])
  403. def record_batch(conn, info):
  404. cur = conn.execute(
  405. "INSERT INTO batches(filename, rows_read, rows_written, new_stores, period, date_from, date_to, "
  406. "created_at, note, file_path) VALUES (?,?,?,?,?,?,?,?,?,?)",
  407. (info.get("filename"), info.get("rows_read"), info.get("rows_written"),
  408. info.get("new_stores"), info.get("period"), info.get("date_from"),
  409. info.get("date_to"), datetime.now().isoformat(timespec="seconds"), info.get("note"),
  410. info.get("file_path")))
  411. return cur.lastrowid
  412. # ---------------------------------------------------------------- 账号
  413. def user_count():
  414. conn = connect()
  415. n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
  416. conn.close()
  417. return n
  418. def get_user_by_name(username):
  419. conn = connect()
  420. r = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
  421. conn.close()
  422. return dict(r) if r else None
  423. def get_user(uid):
  424. conn = connect()
  425. r = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
  426. conn.close()
  427. return dict(r) if r else None
  428. def list_users():
  429. conn = connect()
  430. rows = [dict(r) for r in conn.execute(
  431. "SELECT id, username, role, display_name, active, must_change, created_at, last_login "
  432. "FROM users ORDER BY CASE role WHEN 'admin' THEN 0 WHEN 'editor' THEN 1 ELSE 2 END, id")]
  433. conn.close()
  434. return rows
  435. def create_user(username, password, role, display_name=""):
  436. import auth
  437. h, salt = auth.hash_password(password)
  438. conn = connect()
  439. try:
  440. cur = conn.execute(
  441. "INSERT INTO users(username, pass_hash, salt, role, display_name, active, must_change, created_at) "
  442. "VALUES (?,?,?,?,?,1,0,?)",
  443. (username, h, salt, role, display_name or username,
  444. datetime.now().isoformat(timespec="seconds")))
  445. conn.commit()
  446. return cur.lastrowid
  447. finally:
  448. conn.close()
  449. def update_user(uid, role=None, display_name=None, active=None):
  450. sets, vals = [], []
  451. if role is not None:
  452. sets.append("role=?"); vals.append(role)
  453. if display_name is not None:
  454. sets.append("display_name=?"); vals.append(display_name)
  455. if active is not None:
  456. sets.append("active=?"); vals.append(1 if active else 0)
  457. if not sets:
  458. return
  459. vals.append(uid)
  460. conn = connect()
  461. conn.execute("UPDATE users SET %s WHERE id=?" % ",".join(sets), vals)
  462. conn.commit()
  463. conn.close()
  464. def set_password(uid, password, must_change=0):
  465. import auth
  466. h, salt = auth.hash_password(password)
  467. conn = connect()
  468. conn.execute("UPDATE users SET pass_hash=?, salt=?, must_change=? WHERE id=?",
  469. (h, salt, 1 if must_change else 0, uid))
  470. conn.commit()
  471. conn.close()
  472. def delete_user(uid):
  473. conn = connect()
  474. conn.execute("DELETE FROM sessions WHERE user_id=?", (uid,))
  475. conn.execute("DELETE FROM users WHERE id=?", (uid,))
  476. conn.commit()
  477. conn.close()
  478. def admin_count():
  479. conn = connect()
  480. n = conn.execute("SELECT COUNT(*) FROM users WHERE role='admin' AND active=1").fetchone()[0]
  481. conn.close()
  482. return n
  483. def touch_login(uid):
  484. conn = connect()
  485. conn.execute("UPDATE users SET last_login=? WHERE id=?",
  486. (datetime.now().isoformat(timespec="seconds"), uid))
  487. conn.commit()
  488. conn.close()
  489. # ---------------------------------------------------------------- 会话
  490. def create_session(token_hash, user_id, expires_at, ip=""):
  491. conn = connect()
  492. conn.execute("DELETE FROM sessions WHERE expires_at < ?",
  493. (datetime.now().isoformat(timespec="seconds"),))
  494. conn.execute("INSERT INTO sessions(token_hash, user_id, created_at, expires_at, ip) VALUES (?,?,?,?,?)",
  495. (token_hash, user_id, datetime.now().isoformat(timespec="seconds"), expires_at, ip))
  496. conn.commit()
  497. conn.close()
  498. def get_session(token_hash):
  499. """返回 (user_dict, expires_at) 或 (None, None)"""
  500. conn = connect()
  501. r = conn.execute("SELECT * FROM sessions WHERE token_hash=?", (token_hash,)).fetchone()
  502. if not r:
  503. conn.close()
  504. return None, None
  505. if (r["expires_at"] or "") < datetime.now().isoformat(timespec="seconds"):
  506. conn.execute("DELETE FROM sessions WHERE token_hash=?", (token_hash,))
  507. conn.commit()
  508. conn.close()
  509. return None, None
  510. u = conn.execute("SELECT * FROM users WHERE id=?", (r["user_id"],)).fetchone()
  511. conn.close()
  512. if not u or not u["active"]:
  513. return None, None
  514. return dict(u), r["expires_at"]
  515. def delete_session(token_hash):
  516. conn = connect()
  517. conn.execute("DELETE FROM sessions WHERE token_hash=?", (token_hash,))
  518. conn.commit()
  519. conn.close()
  520. def delete_user_sessions(uid):
  521. conn = connect()
  522. conn.execute("DELETE FROM sessions WHERE user_id=?", (uid,))
  523. conn.commit()
  524. conn.close()
  525. def get_batch(batch_id):
  526. conn = connect()
  527. r = conn.execute("SELECT * FROM batches WHERE id=?", (batch_id,)).fetchone()
  528. conn.close()
  529. return dict(r) if r else None
  530. def latest_batch_with_file(period=None):
  531. """找某个周期(或全局最新)里带原始文件的批次"""
  532. conn = connect()
  533. sql = ("SELECT * FROM batches WHERE file_path IS NOT NULL AND file_path<>''")
  534. args = []
  535. if period:
  536. sql += " AND period=?"
  537. args.append(period)
  538. sql += " ORDER BY id DESC LIMIT 1"
  539. r = conn.execute(sql, args).fetchone()
  540. if not r and period:
  541. r = conn.execute("SELECT * FROM batches WHERE file_path IS NOT NULL AND file_path<>'' "
  542. "ORDER BY id DESC LIMIT 1").fetchone()
  543. conn.close()
  544. return dict(r) if r else None
  545. def import_history(limit=20):
  546. conn = connect()
  547. rows = conn.execute("SELECT * FROM batches ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
  548. conn.close()
  549. return [dict(r) for r in rows]
  550. def stats():
  551. conn = connect()
  552. n_stores = conn.execute("SELECT COUNT(*) c FROM stores").fetchone()["c"]
  553. n_metrics = conn.execute("SELECT COUNT(*) c FROM metrics").fetchone()["c"]
  554. n_batches = conn.execute("SELECT COUNT(*) c FROM batches").fetchone()["c"]
  555. conn.close()
  556. return {"stores": n_stores, "metrics": n_metrics, "batches": n_batches}