storage.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. """SQLite 持久化:注册成功的账号、session 快照、CPA 上传记录。"""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import sqlite3
  6. import threading
  7. import time
  8. from contextlib import contextmanager
  9. from typing import Any, Iterable
  10. DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
  11. DB_PATH = os.path.join(DATA_DIR, "accounts.db")
  12. _LOCK = threading.Lock()
  13. SCHEMA = """
  14. CREATE TABLE IF NOT EXISTS accounts (
  15. email TEXT PRIMARY KEY,
  16. password TEXT NOT NULL,
  17. created_at INTEGER NOT NULL,
  18. updated_at INTEGER NOT NULL,
  19. plan_type TEXT,
  20. final_status TEXT, -- registered/paid/plus/cpa_uploaded/failed/stopped
  21. last_error TEXT,
  22. long_link TEXT,
  23. cpa_file_name TEXT,
  24. cpa_uploaded_at INTEGER,
  25. initial_session_json TEXT, -- 注册成功时拉到的 /api/auth/session
  26. plus_session_json TEXT, -- 付款后拉到的 plus session
  27. notes TEXT
  28. );
  29. CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(final_status);
  30. CREATE INDEX IF NOT EXISTS idx_accounts_created ON accounts(created_at);
  31. CREATE TABLE IF NOT EXISTS account_events (
  32. id INTEGER PRIMARY KEY AUTOINCREMENT,
  33. email TEXT NOT NULL,
  34. ts INTEGER NOT NULL,
  35. stage TEXT NOT NULL,
  36. status TEXT NOT NULL, -- info/ok/warn/error
  37. detail TEXT,
  38. payload_json TEXT
  39. );
  40. CREATE INDEX IF NOT EXISTS idx_events_email ON account_events(email);
  41. CREATE INDEX IF NOT EXISTS idx_events_ts ON account_events(ts);
  42. CREATE TABLE IF NOT EXISTS tasks (
  43. task_id TEXT PRIMARY KEY,
  44. mode TEXT NOT NULL, -- full | pay_only
  45. status TEXT NOT NULL, -- queued | running | success | failed | cancelled
  46. stage TEXT,
  47. attempts INTEGER DEFAULT 0,
  48. max_attempts INTEGER DEFAULT 3,
  49. params_json TEXT,
  50. result_json TEXT,
  51. last_error TEXT,
  52. email TEXT,
  53. plan_type TEXT,
  54. cpa_file_name TEXT,
  55. created_at INTEGER NOT NULL,
  56. updated_at INTEGER NOT NULL,
  57. started_at INTEGER,
  58. finished_at INTEGER
  59. );
  60. CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
  61. CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at);
  62. """
  63. def _now_ms() -> int:
  64. return int(time.time() * 1000)
  65. def _ensure_dir():
  66. os.makedirs(DATA_DIR, exist_ok=True)
  67. def init_db():
  68. _ensure_dir()
  69. with _LOCK, sqlite3.connect(DB_PATH) as conn:
  70. conn.executescript(SCHEMA)
  71. conn.commit()
  72. @contextmanager
  73. def _conn():
  74. _ensure_dir()
  75. with _LOCK:
  76. c = sqlite3.connect(DB_PATH)
  77. c.row_factory = sqlite3.Row
  78. try:
  79. yield c
  80. c.commit()
  81. finally:
  82. c.close()
  83. def _dump(value: Any) -> str | None:
  84. if value is None:
  85. return None
  86. try:
  87. return json.dumps(value, ensure_ascii=False)
  88. except Exception:
  89. return str(value)
  90. def upsert_account(email: str, password: str, *, fields: dict | None = None) -> dict:
  91. """Insert or update by email. fields 中只更新非 None 字段。"""
  92. init_db()
  93. fields = dict(fields or {})
  94. now = _now_ms()
  95. with _conn() as c:
  96. row = c.execute("SELECT email FROM accounts WHERE email = ?", (email,)).fetchone()
  97. if row is None:
  98. c.execute(
  99. """
  100. INSERT INTO accounts (email, password, created_at, updated_at, plan_type,
  101. final_status, last_error, long_link, cpa_file_name, cpa_uploaded_at,
  102. initial_session_json, plus_session_json, notes)
  103. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  104. """,
  105. (
  106. email, password, now, now,
  107. fields.get("plan_type"),
  108. fields.get("final_status") or "registered",
  109. fields.get("last_error"),
  110. fields.get("long_link"),
  111. fields.get("cpa_file_name"),
  112. fields.get("cpa_uploaded_at"),
  113. _dump(fields.get("initial_session")) if "initial_session" in fields else fields.get("initial_session_json"),
  114. _dump(fields.get("plus_session")) if "plus_session" in fields else fields.get("plus_session_json"),
  115. fields.get("notes"),
  116. ),
  117. )
  118. else:
  119. sets = ["updated_at = ?"]
  120. args: list[Any] = [now]
  121. for col in ("plan_type", "final_status", "last_error", "long_link",
  122. "cpa_file_name", "cpa_uploaded_at", "notes"):
  123. if col in fields and fields[col] is not None:
  124. sets.append(f"{col} = ?")
  125. args.append(fields[col])
  126. if "initial_session" in fields:
  127. sets.append("initial_session_json = ?")
  128. args.append(_dump(fields["initial_session"]))
  129. elif "initial_session_json" in fields and fields["initial_session_json"] is not None:
  130. sets.append("initial_session_json = ?")
  131. args.append(fields["initial_session_json"])
  132. if "plus_session" in fields:
  133. sets.append("plus_session_json = ?")
  134. args.append(_dump(fields["plus_session"]))
  135. elif "plus_session_json" in fields and fields["plus_session_json"] is not None:
  136. sets.append("plus_session_json = ?")
  137. args.append(fields["plus_session_json"])
  138. if password:
  139. sets.append("password = ?")
  140. args.append(password)
  141. args.append(email)
  142. c.execute(f"UPDATE accounts SET {', '.join(sets)} WHERE email = ?", args)
  143. return _row_to_dict(c.execute("SELECT * FROM accounts WHERE email = ?", (email,)).fetchone())
  144. def add_event(email: str, stage: str, status: str = "info",
  145. detail: str | None = None, payload: Any = None) -> int:
  146. init_db()
  147. with _conn() as c:
  148. cur = c.execute(
  149. "INSERT INTO account_events (email, ts, stage, status, detail, payload_json) VALUES (?, ?, ?, ?, ?, ?)",
  150. (email or "", _now_ms(), stage, status, detail, _dump(payload)),
  151. )
  152. return cur.lastrowid
  153. def list_accounts(limit: int = 200, status: str | None = None) -> list[dict]:
  154. init_db()
  155. with _conn() as c:
  156. if status:
  157. rows = c.execute(
  158. "SELECT * FROM accounts WHERE final_status = ? ORDER BY created_at DESC LIMIT ?",
  159. (status, limit),
  160. ).fetchall()
  161. else:
  162. rows = c.execute(
  163. "SELECT * FROM accounts ORDER BY created_at DESC LIMIT ?",
  164. (limit,),
  165. ).fetchall()
  166. return [_row_to_dict(r) for r in rows]
  167. def get_account(email: str) -> dict | None:
  168. init_db()
  169. with _conn() as c:
  170. r = c.execute("SELECT * FROM accounts WHERE email = ?", (email,)).fetchone()
  171. return _row_to_dict(r) if r else None
  172. def list_events(email: str, limit: int = 100) -> list[dict]:
  173. init_db()
  174. with _conn() as c:
  175. rows = c.execute(
  176. "SELECT * FROM account_events WHERE email = ? ORDER BY ts DESC LIMIT ?",
  177. (email, limit),
  178. ).fetchall()
  179. return [_event_row(r) for r in rows]
  180. def _row_to_dict(row: sqlite3.Row | None) -> dict | None:
  181. if row is None:
  182. return None
  183. d = {k: row[k] for k in row.keys()}
  184. # session JSON 反序列化但保留 raw 副本
  185. for k in ("initial_session_json", "plus_session_json"):
  186. raw = d.get(k)
  187. if raw:
  188. try:
  189. d[k.replace("_json", "")] = json.loads(raw)
  190. except Exception:
  191. d[k.replace("_json", "")] = None
  192. return d
  193. def _event_row(row: sqlite3.Row) -> dict:
  194. d = {k: row[k] for k in row.keys()}
  195. raw = d.get("payload_json")
  196. if raw:
  197. try:
  198. d["payload"] = json.loads(raw)
  199. except Exception:
  200. d["payload"] = None
  201. return d
  202. # ------------------------- tasks -------------------------
  203. def create_task(task_id: str, mode: str, params: dict, max_attempts: int = 3) -> dict:
  204. init_db()
  205. now = _now_ms()
  206. with _conn() as c:
  207. c.execute(
  208. """
  209. INSERT INTO tasks (task_id, mode, status, stage, attempts, max_attempts,
  210. params_json, result_json, last_error, email, plan_type, cpa_file_name,
  211. created_at, updated_at, started_at, finished_at)
  212. VALUES (?, ?, 'queued', '', 0, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, NULL, NULL)
  213. """,
  214. (task_id, mode, max_attempts, _dump(params or {}), now, now),
  215. )
  216. return _task_row(c.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone())
  217. def update_task(task_id: str, fields: dict) -> dict | None:
  218. init_db()
  219. if not fields:
  220. return get_task(task_id)
  221. sets = ["updated_at = ?"]
  222. args: list[Any] = [_now_ms()]
  223. for col in (
  224. "status", "stage", "attempts", "max_attempts", "last_error",
  225. "email", "plan_type", "cpa_file_name", "started_at", "finished_at",
  226. ):
  227. if col in fields and fields[col] is not None:
  228. sets.append(f"{col} = ?")
  229. args.append(fields[col])
  230. if "result" in fields:
  231. sets.append("result_json = ?")
  232. args.append(_dump(fields["result"]))
  233. elif "result_json" in fields and fields["result_json"] is not None:
  234. sets.append("result_json = ?")
  235. args.append(fields["result_json"])
  236. if "params" in fields:
  237. sets.append("params_json = ?")
  238. args.append(_dump(fields["params"]))
  239. args.append(task_id)
  240. with _conn() as c:
  241. c.execute(f"UPDATE tasks SET {', '.join(sets)} WHERE task_id = ?", args)
  242. row = c.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
  243. return _task_row(row) if row else None
  244. def get_task(task_id: str) -> dict | None:
  245. init_db()
  246. with _conn() as c:
  247. r = c.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
  248. return _task_row(r) if r else None
  249. def list_tasks(limit: int = 100, status: str | None = None) -> list[dict]:
  250. init_db()
  251. with _conn() as c:
  252. if status:
  253. rows = c.execute(
  254. "SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC LIMIT ?",
  255. (status, limit),
  256. ).fetchall()
  257. else:
  258. rows = c.execute(
  259. "SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?",
  260. (limit,),
  261. ).fetchall()
  262. return [_task_row(r) for r in rows]
  263. def get_next_queued_task() -> dict | None:
  264. """取一个最早的 queued 任务(FIFO)。"""
  265. init_db()
  266. with _conn() as c:
  267. r = c.execute(
  268. "SELECT * FROM tasks WHERE status = 'queued' ORDER BY created_at ASC LIMIT 1"
  269. ).fetchone()
  270. return _task_row(r) if r else None
  271. def _task_row(row: sqlite3.Row | None) -> dict | None:
  272. if row is None:
  273. return None
  274. d = {k: row[k] for k in row.keys()}
  275. for k in ("params_json", "result_json"):
  276. raw = d.get(k)
  277. if raw:
  278. try:
  279. d[k.replace("_json", "")] = json.loads(raw)
  280. except Exception:
  281. d[k.replace("_json", "")] = None
  282. return d