storage.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  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. """
  43. def _now_ms() -> int:
  44. return int(time.time() * 1000)
  45. def _ensure_dir():
  46. os.makedirs(DATA_DIR, exist_ok=True)
  47. def init_db():
  48. _ensure_dir()
  49. with _LOCK, sqlite3.connect(DB_PATH) as conn:
  50. conn.executescript(SCHEMA)
  51. conn.commit()
  52. @contextmanager
  53. def _conn():
  54. _ensure_dir()
  55. with _LOCK:
  56. c = sqlite3.connect(DB_PATH)
  57. c.row_factory = sqlite3.Row
  58. try:
  59. yield c
  60. c.commit()
  61. finally:
  62. c.close()
  63. def _dump(value: Any) -> str | None:
  64. if value is None:
  65. return None
  66. try:
  67. return json.dumps(value, ensure_ascii=False)
  68. except Exception:
  69. return str(value)
  70. def upsert_account(email: str, password: str, *, fields: dict | None = None) -> dict:
  71. """Insert or update by email. fields 中只更新非 None 字段。"""
  72. init_db()
  73. fields = dict(fields or {})
  74. now = _now_ms()
  75. with _conn() as c:
  76. row = c.execute("SELECT email FROM accounts WHERE email = ?", (email,)).fetchone()
  77. if row is None:
  78. c.execute(
  79. """
  80. INSERT INTO accounts (email, password, created_at, updated_at, plan_type,
  81. final_status, last_error, long_link, cpa_file_name, cpa_uploaded_at,
  82. initial_session_json, plus_session_json, notes)
  83. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  84. """,
  85. (
  86. email, password, now, now,
  87. fields.get("plan_type"),
  88. fields.get("final_status") or "registered",
  89. fields.get("last_error"),
  90. fields.get("long_link"),
  91. fields.get("cpa_file_name"),
  92. fields.get("cpa_uploaded_at"),
  93. _dump(fields.get("initial_session")) if "initial_session" in fields else fields.get("initial_session_json"),
  94. _dump(fields.get("plus_session")) if "plus_session" in fields else fields.get("plus_session_json"),
  95. fields.get("notes"),
  96. ),
  97. )
  98. else:
  99. sets = ["updated_at = ?"]
  100. args: list[Any] = [now]
  101. for col in ("plan_type", "final_status", "last_error", "long_link",
  102. "cpa_file_name", "cpa_uploaded_at", "notes"):
  103. if col in fields and fields[col] is not None:
  104. sets.append(f"{col} = ?")
  105. args.append(fields[col])
  106. if "initial_session" in fields:
  107. sets.append("initial_session_json = ?")
  108. args.append(_dump(fields["initial_session"]))
  109. elif "initial_session_json" in fields and fields["initial_session_json"] is not None:
  110. sets.append("initial_session_json = ?")
  111. args.append(fields["initial_session_json"])
  112. if "plus_session" in fields:
  113. sets.append("plus_session_json = ?")
  114. args.append(_dump(fields["plus_session"]))
  115. elif "plus_session_json" in fields and fields["plus_session_json"] is not None:
  116. sets.append("plus_session_json = ?")
  117. args.append(fields["plus_session_json"])
  118. if password:
  119. sets.append("password = ?")
  120. args.append(password)
  121. args.append(email)
  122. c.execute(f"UPDATE accounts SET {', '.join(sets)} WHERE email = ?", args)
  123. return _row_to_dict(c.execute("SELECT * FROM accounts WHERE email = ?", (email,)).fetchone())
  124. def add_event(email: str, stage: str, status: str = "info",
  125. detail: str | None = None, payload: Any = None) -> int:
  126. init_db()
  127. with _conn() as c:
  128. cur = c.execute(
  129. "INSERT INTO account_events (email, ts, stage, status, detail, payload_json) VALUES (?, ?, ?, ?, ?, ?)",
  130. (email or "", _now_ms(), stage, status, detail, _dump(payload)),
  131. )
  132. return cur.lastrowid
  133. def list_accounts(limit: int = 200, status: str | None = None) -> list[dict]:
  134. init_db()
  135. with _conn() as c:
  136. if status:
  137. rows = c.execute(
  138. "SELECT * FROM accounts WHERE final_status = ? ORDER BY created_at DESC LIMIT ?",
  139. (status, limit),
  140. ).fetchall()
  141. else:
  142. rows = c.execute(
  143. "SELECT * FROM accounts ORDER BY created_at DESC LIMIT ?",
  144. (limit,),
  145. ).fetchall()
  146. return [_row_to_dict(r) for r in rows]
  147. def get_account(email: str) -> dict | None:
  148. init_db()
  149. with _conn() as c:
  150. r = c.execute("SELECT * FROM accounts WHERE email = ?", (email,)).fetchone()
  151. return _row_to_dict(r) if r else None
  152. def list_events(email: str, limit: int = 100) -> list[dict]:
  153. init_db()
  154. with _conn() as c:
  155. rows = c.execute(
  156. "SELECT * FROM account_events WHERE email = ? ORDER BY ts DESC LIMIT ?",
  157. (email, limit),
  158. ).fetchall()
  159. return [_event_row(r) for r in rows]
  160. def _row_to_dict(row: sqlite3.Row | None) -> dict | None:
  161. if row is None:
  162. return None
  163. d = {k: row[k] for k in row.keys()}
  164. # session JSON 反序列化但保留 raw 副本
  165. for k in ("initial_session_json", "plus_session_json"):
  166. raw = d.get(k)
  167. if raw:
  168. try:
  169. d[k.replace("_json", "")] = json.loads(raw)
  170. except Exception:
  171. d[k.replace("_json", "")] = None
  172. return d
  173. def _event_row(row: sqlite3.Row) -> dict:
  174. d = {k: row[k] for k in row.keys()}
  175. raw = d.get("payload_json")
  176. if raw:
  177. try:
  178. d["payload"] = json.loads(raw)
  179. except Exception:
  180. d["payload"] = None
  181. return d